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>
14 KiB
kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, body, assets, evidence, source
| kind | slug | title | topic | project | status | sourceRevision | rootTreeNode | evidenceCapturedOn | body | assets | evidence | source | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | a13-f009-sigv4-string | 지운다고 약속한 사본은 아무도 받지 않고, 서명 자리가 받는 사본은 지울 수 없다 | notification-and-delivery | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:a13-f009-sigv4-string | 2026-09-02 | case-a13-f009-sigv4-string.body.md |
|
|
|
지운다고 약속한 사본은 아무도 받지 않고, 서명 자리가 받는 사본은 지울 수 없다
이 리프에는 비밀의 모양이 둘이다. 닫으면 자신을 0 으로 덮는 핸들과, 닫기가 없는 키 자재 복제다. 핸들을 받는 인터페이스의 구현은 시험 소스에만 있고, 서명 자리가 실제로 받는 것은 복제 쪽이다.
관계
- 포트를 뺀 호스트를 서명하는 자리가 둘이고, 같은 저장소의 네 자리는 그 포트를 지킨다 같은 SigV4 경로의 다른 자리다.
- 비밀을 힙에서 지우는 마지막 단계가 종료 경로에 연결되지 않았다 지우는 장치가 있는데 그 손이 닿지 않는다는 점이 같다.
- 정책을 담은 필드가 참이 된 적도 읽힌 적도 없다 선언한 보호 장치가 실제 경로에 없다는 점이 같다.
문제
원문은 서명 키 파생 한 줄이 지울 수 있는 비밀 사본을 불변 문자열로 올린다고 적었다. 그 사본이 정말 그 자리까지 오는지, 그리고 힙에 무엇이 남는지 확인했다.
결론
오지 않는다. 두 모양이 서로 만나지 않는다.
지운다고 약속한 쪽은 핸들이다. 자바독이 닫을 때 자신을 지우는 연산 범위 사본이라고 적고, 닫기가 배열과 문자 배열을 각각 0 으로 채운다. 그 핸들을 받는 것은 제공자 시도 클라이언트 인터페이스다. 그 인터페이스를 구현하는 일곱 자리가 모두 시험 쪽에 있다. 핸들을 여는 두 어댑터도 main 에 있지만 main 에서 조립되지 않는다.
복제를 받는 길을 가르는 것은 목적이다. 제공자 계정 자격증명만 관리자를 거친다. 세대 범위가 붙은 유일한 비밀이라서다. 나머지 목적은 전부 제공자를 바로 조회하고, 웹훅 요청 서명과 VAPID 서명도 그 길을 탄다. 어느 길이든 돌아오는 것은 키 자재 레코드의 접근자가 만든 복제 배열이다. 그 레코드는 하나를 보관하고, 접근자가 불릴 때마다 한 벌씩 더 만든다. 지우는 메서드는 없다. 닫기가 사본에 닿지 못하는 것이 아니라 닫을 주체가 없다.
그 복제가 불변 문자열이 되는 자리는 셋이다. SES 서명 키 파생, Twilio 요청 매퍼, Twilio 조정 능력이다. 원문은 첫 자리만 셌다.
셋 모두 바이트를 문자열로 만들어 이어 붙인다. SES 는 그것을 다시 바이트로 되돌리고, Twilio 둘은 base64 문자열로 만든다.
힙에 무엇이 남는지 네 모드로 재봤다.
기준선은 둘과 하나다. 그 하나는 아무도 지울 수 없는 쪽이다. 다만 프로브는 받은 배열을 지운다. 실제 세 호출자는 그 배열을 인자 자리에 바로 넘기므로 지울 지역 변수조차 없고, 접근자는 부를 때마다 새 복제를 만든다.
서명을 지나면 거기서 넷이 늘었다.
바이트로만 이으면 그 넷 가운데 둘이 사라진다. 이은 배열까지 지우면 셋이다. 두 모드에 공통으로 살아남는 것이 키 명세가 만드는 복제다.
이웃 하나는 같은 자리에서 다른 선택을 했다. Twilio 서명 검증기는 인증 토큰을 바이트 그대로 키 명세에 넣는다. 다만 그 토큰은 다른 비밀이다. 콜백 서명 목적으로 조회되고 목적 검사까지 받는다.
SES 서명기 자신도 키 명세를 만든다. 거기 들어가는 배열이 바로 그 문자열에서 나온 것이다.
기능 쪽 위험도 하나 있다. 실제 AWS 키는 아스키라 왕복이 값을 바꾸지 않지만, 코드는 그것을 요구하지 않는다. 비아스키 바이트가 섞이면 파생 키가 조용히 달라진다.
판정은 P3 다. 근거가 둘이다. 힙에 닿을 수 있어야 회수된다는 것, 그리고 세 자리를 담은 클래스 가운데 둘은 시험 소스에서만 만들어지고 나머지 하나는 어디에서도 만들어지지 않는다는 것이다.
따르는 선례는 websocket 리프다. 거기서 도달 가능한 등급을 한 단계 내렸다. 배선되지 않았다는 이유로 P3 을 바로 매긴 grpc 리프는 따르지 않는다. grpc 리프는 런타임 소속이 비어 있고 이 리프는 app-bootstrap 에 올라 있다. 소속이 등급을 가른다는 규칙이 이 저장소에 따로 적혀 있다.
그래서 완화가 선례보다 약해 보인다. websocket 쪽은 기계가 강제하는 빌드 전용 등급을 근거로 한 단계를 낮췄는데, 여기서는 조립기가 없다는 것뿐이고 그 부재는 포크가 채우라고 기동 메시지가 직접 권한다.
그래도 한 단계를 낮추는 근거는 선례와 같다. websocket 선례가 실제로 물은 것은 소속이 아니라 출하되는 조합에서 그 시나리오가 일어날 수 있느냐였다. 조립기 없는 제공자 프로필을 설정하면 기동이 실패하므로, 여기서도 출하되는 어떤 조합에서도 이 실패는 일어나지 않는다. 조립되면 P2 다.
검증 환경
OpenJDK : 21.0.12 확인 방식 : 비밀 두 모양의 호출 그래프 추적, 네 모드를 새 JVM 에서 각각 실행하고 힙 덤프 두 종 소스 수정 : x
재현 조건
이 사례의 프로브는 새로 만든 것이고 원문에는 없다. 원문 근거는 분석 문서의 #L844 절이다.
- 핸들의 자바독과 닫기 구현을 읽는다.
- 그 핸들을 받는 인터페이스의 구현을 이름 있는 것과 익명인 것 모두 센다.
- 서명 자리에 비밀을 넘기는 호출을 거꾸로 따라간다.
- 그 끝에 있는 키 자재 레코드가 사본을 어떻게 만드는지 읽는다.
- 그 사본을 문자열로 올리는 자리를 리프에서 찾는다.
- 레코드를 실제 경로와 같게 만들고 힙을 두 종류로 뜬다.
- 받기만 하는 모드, 바이트로만 잇는 모드, 이은 배열까지 지우는 모드, 서명하는 모드를 하나씩 돌린다.
- 서명 검증기가 쓰는 비밀의 목적이 무엇인지 확인한다.
본문
핸들의 존재 이유가 한 줄에 적혀 있다.
// NotificationSecretMaterialHandle.java:7, :60-70
/** Versioned operation-scoped mutable secret copy that wipes itself on close. */
public synchronized void close() {
if (!closed) {
if (bytes != null) {
Arrays.fill(bytes, (byte) 0);
}
if (characters != null) {
Arrays.fill(characters, '\0');
}
closed = true;
}
}
그 사본은 서명 자리에 오지 않는다
:::evidence key="a13-f009-sigv4-string" alt="코드베이스 정적 검색 출력 62줄. 핸들의 약속과 지우는 줄, 그 핸들을 받는 인터페이스의 구현 일곱, 자격증명 관리자와 키 자재 레코드가 사본을 만드는 줄, 그 사본을 서명 자리로 넘기는 세 자리, 문자열로 올리는 세 자리, 그 문자열이 키 명세로 들어가는 줄, 제공자를 바로 조회하는 열세 줄과 그중 관리자 자신의 둘, 제공자 자격증명 목적을 푸는 두 줄, 서명 검증기가 쓰는 비밀의 목적이 차례로 보인다." caption="두 비밀 모양과 그 끝 — 62줄 · exit 0" zoom="true" :::
핸들을 받는 것은 NotificationProviderAttemptClient 다. 그 인터페이스를 구현하는 자리는 저장소에 일곱이고 전부 시험 소스다. 이름 있는 것은 NotificationReconciliationAdapterTest:164 하나이고 나머지 여섯은 익명 클래스다. 메서드가 둘이라 implements 만 찾는 검색으로는 여섯이 보이지 않는다.
서명 자리가 받는 것은 다른 길에서 온다.
// ProviderCredentialManager.java:145-153
public byte[] materialFor(ProviderProfileId profileId, long generation) {
...
if (active.generation() == generation) {
return material(active).material();
// SecretKeyMaterial.java:20, :23-26
material = material.clone();
@Override
public byte[] material() {
return material.clone();
}
레코드가 생성자에서 한 벌을 보관하고 접근자가 부를 때마다 한 벌을 더 만든다. 어느 쪽에도 닫기가 없다. SesNotificationProviderAdapter:136, TwilioSmsProviderAdapter:98, TwilioReconciliationCapability:125 가 그 배열을 인자 자리에 그대로 넘긴다. 지울 지역 변수를 두는 자리는 하나도 없다.
이 길을 타는 것은 제공자 계정 자격증명뿐이다. 목적을 PROVIDER_CREDENTIAL 로 확인하는 자리가 ProviderCredentialManager:199 와 :224 둘뿐이고 둘 다 관리자 안이다. 검사 없이 그 목적을 바로 조회하는 자리는 자료가 세어 본 대로 0 이다.
나머지 목적은 비밀 자재 제공자를 바로 조회한다. 자료의 그 묶음이 열세 줄인데 둘은 관리자 자신의 것이므로 밖에서 부르는 것은 열하나다. 웹훅 요청 서명(WebhookNotificationProviderAdapter:220)과 VAPID 서명(VapidKeyRegistry:67)도 제공자를 바로 조회하므로, 서명이냐 검증이냐로 갈리지 않는다. 아래에서 볼 검증기의 토큰도 여기서 온다.
그 배열을 문자열로 올리는 자리가 셋이다
// AwsSignatureV4Signer.java:117-119
byte[] key =
("AWS4" + new String(secretAccessKey, StandardCharsets.UTF_8))
.getBytes(StandardCharsets.UTF_8);
// TwilioRequestMapper.java:41-45
String credentials =
Base64.getEncoder()
.encodeToString(
(properties.accountSid() + ":" + new String(authToken, StandardCharsets.UTF_8))
.getBytes(StandardCharsets.UTF_8));
TwilioReconciliationCapability:119-128 이 같은 식을 한 번 더 쓴다. 다른 점은 바이트의 출처가 credentials.materialFor(...) 라고 그 자리에 직접 적혀 있다는 것뿐이다.
Twilio 두 자리는 SES 보다 넓다. 문자열이 되는 것이 토큰 하나가 아니라 accountSid:token 전체이고, 그것을 base64 로 부호화한 문자열도 함께 남는다.
힙에 몇 벌이 남는가
:::evidence key="a13-f009-sigv4-string-heap" alt="JVM 프로브 출력 7줄. 키 자재 레코드에서 받은 사본을 지운 뒤 힙 덤프 두 종에서 비밀 표식이 몇 번 나오는지를 네 모드에 대해 적었다. 표식은 아스키 코드로 조립한 합성 값이다." caption="네 모드에서 힙에 남은 자격증명 사본 — 7줄 · exit 0" zoom="true" :::
실제 경로와 같은 모양으로 레코드를 만들고, 접근자가 준 배열을 쓴 뒤 지웠다. 네 모드를 각각 새 JVM 에서 돌렸다.
받아서 지우기만 한 모드가 기준선이다. 모든 객체 덤프에서 둘, 살아 있는 객체 덤프에서 하나다. 그 하나는 레코드가 계속 들고 있는 사본이다.
서명 키 파생을 지난 모드는 여섯이다. 기준선보다 넷이 많다.
문자열을 거치지 않고 바이트로만 이은 모드는 넷이다. 늘어난 넷 중 둘이 없어진 것이다. 원문이 제안하는 수정이 딱 여기까지다.
그 배열까지 지운 모드는 셋이다. 그 지우기는 제안에 없는 별도의 한 걸음이고, 지금 서명기도 파생 배열을 지우지 않는다. 어느 쪽이든 남는 하나는 SecretKeySpec 이 만드는 복제다. 두 모드 모두 HMAC 네 바퀴를 서명기와 똑같이 돈다.
프로브는 비밀을 문자열 리터럴로 적지 않는다. 아스키 코드 배열로 조립한다. 찾을 바이트열도 덤프를 뜬 뒤에 만든다. 둘 다 프로브 자신이 세어지는 것을 막기 위한 것이다.
이웃이 쓰는 것은 다른 비밀이다
// TwilioSignatureValidator.java:36
mac.init(new SecretKeySpec(authToken, "HmacSHA1"));
바이트를 그대로 넣는다. 다만 이 authToken 은 매퍼가 쓰는 것과 다른 비밀이다. TwilioCallbackAdapter:78-79 가 콜백 서명 키 참조로 조회하고 목적이 CALLBACK_SIGNING 인지 검사한다. 매퍼 쪽은 PROVIDER_CREDENTIAL 이다.
키 명세를 만드는 자리가 전부 바이트를 쓴다고도 말할 수 없다. AwsSignatureV4Signer:129 도 키 명세를 만드는데, 거기 들어가는 key 가 :117-119 의 문자열에서 나온 배열이다.
값이 상할 여지
AWS 가 발급하는 비밀 키는 아스키라 실제로는 UTF-8 왕복이 값을 바꾸지 않는다. 코드가 그것을 강제하지는 않는다. 자격증명은 base64 블롭에서 바인딩되고 길이만 검사받으므로, 0x80 이상 바이트가 들어오면 왕복이 그것을 대체 문자로 바꾸고 서명 키가 조용히 달라진다.
확인하지 못한 것
덤프에 남은 것들의 객체 정체는 코드를 읽어 짚었고 덤프 안에서 확인하지는 않았다. 프로브가 센 것은 바이트열의 출현 횟수다. Twilio 두 자리는 정적으로만 읽었고 힙으로 재현하지는 않았다. 0x80 이상 바이트가 실제로 서명을 깨는지도 재현하지 않았다.