The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
183 lines
13 KiB
XML
183 lines
13 KiB
XML
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1602" viewBox="0 0 1200 1602" role="img">
|
|
<title>terminal evidence</title>
|
|
<desc>Terminal-style rendering generated from retained command output. Sensitive-looking values are redacted in the visual asset.</desc>
|
|
<rect x="1" y="1" width="1198" height="1600" rx="14" fill="#0d1117" stroke="#30363d"/>
|
|
<rect x="1" y="1" width="1198" height="44" rx="14" fill="#161b22"/>
|
|
<rect x="1" y="30" width="1198" height="14" fill="#161b22"/>
|
|
<circle cx="24" cy="22" r="6" fill="#ff5f57"/>
|
|
<circle cx="44" cy="22" r="6" fill="#febc2e"/>
|
|
<circle cx="64" cy="22" r="6" fill="#28c840"/>
|
|
<text x="92" y="27" fill="#8b949e" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">terminal evidence</text>
|
|
<text x="24" y="68" fill="#c9d1d9" font-size="15" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">$ set -e
|
|
set -o pipefail
|
|
D=$(mktemp -d); trap 'rm -rf "$D"' EXIT
|
|
C=/shared/codebase/clean-architecture-backend-template
|
|
</tspan><tspan x="24" dy="22">N=$C/src/adapter/outbound/notification
|
|
CACHE=/root/.gradle/caches/modules-2/files-2.1
|
|
java -version 2>&1 | head -1
|
|
cp=""</tspan><tspan x="24" dy="22">
|
|
while IFS= read -r line; do
|
|
coord=${line%%=*}; confs=${line#*=}
|
|
case ",$confs," in *",runtimeClasspath,"*) ;; *) co</tspan><tspan x="24" dy="22">ntinue ;; esac
|
|
g=${coord%%:*}; rest=${coord#*:}; n=${rest%%:*}; v=${rest##*:}
|
|
jar=$(find "$CACHE/$g/$n/$v" -name '*.</tspan><tspan x="24" dy="22">jar' ! -name '*sources*' ! -name '*javadoc*' 2>/dev/null | head -1)
|
|
[ -n "$jar" ] && cp="$cp:$jar"
|
|
done < <(grep -E '^</tspan><tspan x="24" dy="22">[a-zA-Z0-9._-]+:[^=]+=' "$N/gradle.lockfile")
|
|
CP=$(find "$N/build/libs" "$C/src/application-core/build/libs" -name '*+21</tspan><tspan x="24" dy="22">234e38cdb9.jar' | tr '\n' ':')${cp#:}
|
|
cat > "$D/SecretResidueProbe.java" <<'JAVA'
|
|
import com.sun.management.HotSpotDiagn</tspan><tspan x="24" dy="22">osticMXBean;
|
|
import dev.caskeleton.adapter.outbound.notification.platform.provider.ses.AwsSignatureV4Signer;
|
|
import dev.</tspan><tspan x="24" dy="22">caskeleton.application.notification.platform.security.SecretKeyMaterial;
|
|
import dev.caskeleton.application.notification.</tspan><tspan x="24" dy="22">platform.security.SecretPurpose;
|
|
import java.lang.management.ManagementFactory;
|
|
import java.nio.charset.StandardCharsets</tspan><tspan x="24" dy="22">;
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Path;
|
|
import java.time.Instant;
|
|
import java.util.Arrays;
|
|
import java.</tspan><tspan x="24" dy="22">util.Map;
|
|
|
|
/** 제공자 자격증명이 서명 경로를 지난 뒤 힙에 몇 벌 남는지 잰다. */
|
|
public final class SecretResidueProbe {
|
|
</tspan><tspan x="24" dy="22">
|
|
/**
|
|
* 이 프로브가 만든 합성 비밀. 아스키 코드로 조립한다.
|
|
*
|
|
* <p>문자열 리터럴로 적으면 그 리터럴이 상수 풀</tspan><tspan x="24" dy="22">에 남아 측정이 오염된다. 재는 것은 어떤
|
|
* 사본이 남느냐이므로, 프로브 자신은 비밀을 문자열로 만들지 않는다.
|
|
*/
|
|
p</tspan><tspan x="24" dy="22">rivate static byte[] secretBytes() {
|
|
int[] codes = {90, 90, 80, 82, 79, 66, 69, 79, 78, 76, 89, 83, 69, 67, 82, 69, </tspan><tspan x="24" dy="22">84,
|
|
48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 97, 98, 99, 100, 101, 102, 103};
|
|
byte[] bytes = new b</tspan><tspan x="24" dy="22">yte[codes.length];
|
|
for (int at = 0; at < codes.length; at++) {
|
|
bytes[at] = (byte) codes[at];
|
|
}
|
|
return </tspan><tspan x="24" dy="22">bytes;
|
|
}
|
|
|
|
private static int occurrences(Path dump, byte[] needle) throws Exception {
|
|
byte[] data = Files.readAl</tspan><tspan x="24" dy="22">lBytes(dump);
|
|
int found = 0;
|
|
outer:
|
|
for (int at = 0; at + needle.length <= data.length; at++) {
|
|
for (i</tspan><tspan x="24" dy="22">nt offset = 0; offset < needle.length; offset++) {
|
|
if (data[at + offset] != needle[offset]) {
|
|
continue</tspan><tspan x="24" dy="22"> outer;
|
|
}
|
|
}
|
|
found++;
|
|
}
|
|
return found;
|
|
}
|
|
|
|
/** 덤프만 뜬다. 찾을 바이트열은 덤프를 뜬 뒤에 </tspan><tspan x="24" dy="22">만들어야 그 배열 자신이 세어지지 않는다. */
|
|
private static Path dump(Path directory, String name, boolean liveOnly) th</tspan><tspan x="24" dy="22">rows Exception {
|
|
HotSpotDiagnosticMXBean diagnostics =
|
|
ManagementFactory.getPlatformMXBean(HotSpotDiagnostic</tspan><tspan x="24" dy="22">MXBean.class);
|
|
Path file = directory.resolve(name);
|
|
Files.deleteIfExists(file);
|
|
diagnostics.dumpHeap(file.to</tspan><tspan x="24" dy="22">String(), liveOnly);
|
|
return file;
|
|
}
|
|
|
|
private static byte[] hmac(byte[] key, String data) throws Exception {
|
|
</tspan><tspan x="24" dy="22">javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
|
|
mac.init(new javax.crypto.spec.SecretKeySpec(key,</tspan><tspan x="24" dy="22"> "HmacSHA256"));
|
|
return mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
|
|
}
|
|
|
|
/**
|
|
* 원문이 제안하는 수정. 문</tspan><tspan x="24" dy="22">자열을 거치지 않고 바이트끼리 잇되, 그 뒤 HMAC 네 바퀴는
|
|
* 서명기와 똑같이 돈다. 파생 자체를 빼면 수정의 값을 잴 수 </tspan><tspan x="24" dy="22">없다.
|
|
*
|
|
* @param wipeJoined 이은 배열까지 지울지. 제안된 수정에는 이 단계가 없다. 그것을
|
|
* 더하면 무엇이 더</tspan><tspan x="24" dy="22"> 사라지는지 따로 재려고 인자로 뺐다.
|
|
*/
|
|
private static byte[] derivedWithoutString(byte[] secret, boolean wipeJoine</tspan><tspan x="24" dy="22">d) throws Exception {
|
|
byte[] prefix = "AWS4".getBytes(StandardCharsets.US_ASCII);
|
|
byte[] joined = new byte[prefi</tspan><tspan x="24" dy="22">x.length + secret.length];
|
|
System.arraycopy(prefix, 0, joined, 0, prefix.length);
|
|
System.arraycopy(secret, 0, jo</tspan><tspan x="24" dy="22">ined, prefix.length, secret.length);
|
|
byte[] dateKey = hmac(joined, "20260903");
|
|
byte[] regionKey = hmac(dateKey,</tspan><tspan x="24" dy="22"> "eu-central-1");
|
|
byte[] serviceKey = hmac(regionKey, "ses");
|
|
byte[] signingKey = hmac(serviceKey, "aws4_request</tspan><tspan x="24" dy="22">");
|
|
if (wipeJoined) {
|
|
Arrays.fill(joined, (byte) 0);
|
|
}
|
|
return signingKey;
|
|
}
|
|
|
|
public static void ma</tspan><tspan x="24" dy="22">in(String[] args) throws Exception {
|
|
Path directory = Path.of(args[0]);
|
|
String mode = args[1];
|
|
|
|
// 실제 경로</tspan><tspan x="24" dy="22">가 주는 것과 같은 사본이다. 레코드가 하나를 보관하고 접근자가 한 벌 더 준다.
|
|
SecretKeyMaterial stored =
|
|
new </tspan><tspan x="24" dy="22">SecretKeyMaterial("probe-key", SecretPurpose.PROVIDER_CREDENTIAL, secretBytes());
|
|
byte[] handed = stored.material();</tspan><tspan x="24" dy="22">
|
|
|
|
String outcome;
|
|
switch (mode) {
|
|
case "sign" ->
|
|
outcome =
|
|
new AwsSignatureV4Signe</tspan><tspan x="24" dy="22">r()
|
|
.sign("POST", "/v2/email/outbound-emails", "",
|
|
Map.of("host", "email.eu-cent</tspan><tspan x="24" dy="22">ral-1.amazonaws.com",
|
|
"content-type", "application/json"),
|
|
"{}".getBytes</tspan><tspan x="24" dy="22">(StandardCharsets.UTF_8),
|
|
"AKIAPROBEONLY", handed, "eu-central-1", "ses",
|
|
In</tspan><tspan x="24" dy="22">stant.parse("2026-09-03T00:00:00Z"))
|
|
.authorization()
|
|
.substring(0, 16);
|
|
case </tspan><tspan x="24" dy="22">"bytes" -> outcome = "파생 키 " + derivedWithoutString(handed, false).length + "바이트";
|
|
case "byteswipe" ->
|
|
</tspan><tspan x="24" dy="22"> outcome = "파생 키 " + derivedWithoutString(handed, true).length + "바이트";
|
|
default -> outcome = "받은 길이 </tspan><tspan x="24" dy="22">" + handed.length;
|
|
}
|
|
// 호출자가 할 수 있는 일은 여기까지다. 받은 사본은 지운다.
|
|
Arrays.fill(handed, (byte) </tspan><tspan x="24" dy="22">0);
|
|
|
|
Path all = dump(directory, "all.hprof", false);
|
|
Path live = dump(directory, "live.hprof", true);
|
|
byte[]</tspan><tspan x="24" dy="22"> needle = secretBytes();
|
|
String label =
|
|
switch (mode) {
|
|
case "sign" -> "서명 키 파생을 지난 뒤";
|
|
</tspan><tspan x="24" dy="22"> case "bytes" -> "바이트로만 이은 뒤";
|
|
case "byteswipe" -> "이은 배열까지 지운 뒤";
|
|
default </tspan><tspan x="24" dy="22">-> "받아서 지우기만 한 뒤";
|
|
};
|
|
System.out.printf(" %-26s 모든 객체 %-4d 살아 있는 객체만 %-4d (%s)%n",
|
|
</tspan><tspan x="24" dy="22"> label, occurrences(all, needle), occurrences(live, needle), outcome);
|
|
Files.deleteIfExists(all);
|
|
Files.delet</tspan><tspan x="24" dy="22">eIfExists(live);
|
|
// 레코드를 여기까지 붙들어 둔다. 네 모드의 기준선을 같게 두려는 것이다.
|
|
if (stored.keyId().isE</tspan><tspan x="24" dy="22">mpty()) {
|
|
throw new IllegalStateException("unreachable");
|
|
}
|
|
}
|
|
}
|
|
JAVA
|
|
javac -encoding UTF-8 -cp "$CP" -d "$D"</tspan><tspan x="24" dy="22"> "$D/SecretResidueProbe.java"
|
|
echo
|
|
echo "[제공자 자격증명 사본이 힙 덤프에서 몇 번 나오는가]"
|
|
# 네 모드를 각각 새 JVM 에</tspan><tspan x="24" dy="22">서 돌린다. 한 JVM 에서 이어 돌리면 앞 모드가 남긴 것이 뒤 측정에 섞인다.
|
|
java -Dstdout.encoding=UTF-8 -Xmx256m -cp "$CP:</tspan><tspan x="24" dy="22">$D" SecretResidueProbe "$D" read
|
|
java -Dstdout.encoding=UTF-8 -Xmx256m -cp "$CP:$D" SecretResidueProbe "$D" bytes
|
|
java -</tspan><tspan x="24" dy="22">Dstdout.encoding=UTF-8 -Xmx256m -cp "$CP:$D" SecretResidueProbe "$D" byteswipe
|
|
java -Dstdout.encoding=UTF-8 -Xmx256m -cp</tspan><tspan x="24" dy="22"> "$CP:$D" SecretResidueProbe "$D" sign
|
|
</tspan></text>
|
|
<text x="24" y="1344" fill="#8b949e" font-size="13" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">cwd: /shared/codebase/clean-architecture-backend-template/src</tspan></text>
|
|
<text x="24" y="1366" fill="#8b949e" font-size="13" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">time: 2026-09-02T19:12:29+00:00 · exit 0</tspan></text>
|
|
<line x1="24" y1="1380" x2="1176" y2="1380" stroke="#30363d"/>
|
|
<text x="24" y="1420" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">openjdk version "21.0.12" 2026-07-21</tspan></text>
|
|
<text x="24" y="1442" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"></tspan></text>
|
|
<text x="24" y="1464" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">[제공자 자격증명 사본이 힙 덤프에서 몇 번 나오는가]</tspan></text>
|
|
<text x="24" y="1486" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 받아서 지우기만 한 뒤 모든 객체 2 살아 있는 객체만 1 (받은 길이 34)</tspan></text>
|
|
<text x="24" y="1508" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 바이트로만 이은 뒤 모든 객체 4 살아 있는 객체만 1 (파생 키 32바이트)</tspan></text>
|
|
<text x="24" y="1530" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 이은 배열까지 지운 뒤 모든 객체 3 살아 있는 객체만 1 (파생 키 32바이트)</tspan></text>
|
|
<text x="24" y="1552" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 서명 키 파생을 지난 뒤 모든 객체 6 살아 있는 객체만 1 (AWS4-HMAC-SHA256)</tspan></text>
|
|
</svg>
|