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>
207 lines
14 KiB
Markdown
207 lines
14 KiB
Markdown
---
|
|
kind: CASE
|
|
slug: a13-f009-sigv4-string
|
|
title: 지운다고 약속한 사본은 아무도 받지 않고, 서명 자리가 받는 사본은 지울 수 없다
|
|
topic: notification-and-delivery
|
|
project: clean-architecture-backend-template
|
|
status: 게시 전
|
|
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
rootTreeNode: case:a13-f009-sigv4-string
|
|
evidenceCapturedOn: 2026-09-02
|
|
body: case-a13-f009-sigv4-string.body.md
|
|
assets:
|
|
- key: a13-f009-sigv4-string
|
|
file: ../../../final/evidence/rendered/a13-f009-sigv4-string.svg
|
|
- key: a13-f009-sigv4-string-heap
|
|
file: ../../../final/evidence/rendered/a13-f009-sigv4-string-heap.svg
|
|
evidence:
|
|
- ../../../final/evidence/raw/a13-f009-sigv4-string.txt
|
|
- ../../../final/evidence/raw/a13-f009-sigv4-string-heap.txt
|
|
source:
|
|
- 원본 분석 절은 analysis/13-adapter-outbound-notification.md#L844 이다.
|
|
---
|
|
|
|
# 지운다고 약속한 사본은 아무도 받지 않고, 서명 자리가 받는 사본은 지울 수 없다
|
|
|
|
이 리프에는 비밀의 모양이 둘이다. 닫으면 자신을 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 절이다.
|
|
|
|
1. 핸들의 자바독과 닫기 구현을 읽는다.
|
|
2. 그 핸들을 받는 인터페이스의 구현을 이름 있는 것과 익명인 것 모두 센다.
|
|
3. 서명 자리에 비밀을 넘기는 호출을 거꾸로 따라간다.
|
|
4. 그 끝에 있는 키 자재 레코드가 사본을 어떻게 만드는지 읽는다.
|
|
5. 그 사본을 문자열로 올리는 자리를 리프에서 찾는다.
|
|
6. 레코드를 실제 경로와 같게 만들고 힙을 두 종류로 뜬다.
|
|
7. 받기만 하는 모드, 바이트로만 잇는 모드, 이은 배열까지 지우는 모드, 서명하는 모드를 하나씩 돌린다.
|
|
8. 서명 검증기가 쓰는 비밀의 목적이 무엇인지 확인한다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
핸들의 존재 이유가 한 줄에 적혀 있다.
|
|
|
|
```java
|
|
// 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` 만 찾는 검색으로는 여섯이 보이지 않는다.
|
|
|
|
서명 자리가 받는 것은 다른 길에서 온다.
|
|
|
|
```java
|
|
// ProviderCredentialManager.java:145-153
|
|
public byte[] materialFor(ProviderProfileId profileId, long generation) {
|
|
...
|
|
if (active.generation() == generation) {
|
|
return material(active).material();
|
|
```
|
|
|
|
```java
|
|
// 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`)도 제공자를 바로 조회하므로, 서명이냐 검증이냐로 갈리지 않는다. 아래에서 볼 검증기의 토큰도 여기서 온다.
|
|
|
|
## 그 배열을 문자열로 올리는 자리가 셋이다
|
|
|
|
```java
|
|
// AwsSignatureV4Signer.java:117-119
|
|
byte[] key =
|
|
("AWS4" + new String(secretAccessKey, StandardCharsets.UTF_8))
|
|
.getBytes(StandardCharsets.UTF_8);
|
|
```
|
|
|
|
```java
|
|
// 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 네 바퀴를 서명기와 똑같이 돈다.
|
|
|
|
프로브는 비밀을 문자열 리터럴로 적지 않는다. 아스키 코드 배열로 조립한다. 찾을 바이트열도 덤프를 뜬 뒤에 만든다. 둘 다 프로브 자신이 세어지는 것을 막기 위한 것이다.
|
|
|
|
## 이웃이 쓰는 것은 다른 비밀이다
|
|
|
|
```java
|
|
// 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 이상 바이트가 실제로 서명을 깨는지도 재현하지 않았다.
|
|
|
|
<!-- body:end -->
|