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>
13 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-f003-accesscontext | 정책을 담은 필드가 참이 된 적도 읽힌 적도 없다 | notification-and-delivery | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:a13-f003-accesscontext | 2026-09-02 | case-a13-f003-accesscontext.body.md |
|
|
|
정책을 담은 필드가 참이 된 적도 읽힌 적도 없다
접근 문맥 레코드와 그것을 받는 포트가 모든 평문 노출이 감사 가능하다고 두 번 선언한다. 저장소에서 그 레코드를 만드는 길은 감사 필요 여부를 거짓으로 고정한 팩토리 하나뿐이고, 세 필드 중 어느 것도 읽는 코드가 없다.
관계
- 소비자가 없는 fixture 셋 이쪽도 선언된 타입의 프로덕션 호출자를 세어 0 을 확인했다.
- 타입이 문서화한 불변식은 타입이 강제한다 이 타입도 javadoc 으로 불변식을 적어 두고 그것을 강제하지 않는다.
- 실패 사유가 저널에도 감사 이벤트에도 남지 않는다 기록할 설비는 있는데 이 경로에서만 아무것도 남지 않는다.
문제
복호화 포트가 값과 함께 접근 문맥을 받는다.
문맥 레코드의 자바독은 평문 연락처가 왜 노출되는지를 담으며 모든 노출이 감사 가능하다고 적는다. 포트의 자바독은 감사되는 목적을 위해 값을 복호화한다고 적는다.
결론
유일한 구현은 문맥을 널 검사만 하고 버린다.
복호화 메서드에서 문맥을 언급하는 줄은 서명과 널 검사 두 개다. 나머지는 키를 찾고 복호화하고 파싱한다.
문맥을 바꿔 가며 같은 값을 복호화해 봤다. 감사 필요를 참으로 두고 목적 코드를 바꿔도 돌아오는 것이 같다. 복호화 메서드 본문에 감사를 부르는 호출이 하나도 없고, 복호화기가 들고 있는 것도 키 제공자와 난수뿐이다.
접근자 계수는 다른 각도에서 같은 자리를 짚는다. 목적 코드와 감사 필요 여부는 접근자 호출이 각각 0 이다. 행위자 참조 접근자는 일곱 건 잡히지만 전부 다른 타입의 동명 접근자다. 관리자 행위자 쪽과 감사 사건 쪽이 각각 자기 필드를 읽은 것이다.
읽히지 않는 것만이 아니다. 이 레코드를 만드는 길이 하나뿐이고, 그 팩토리가 감사 필요 여부를 거짓으로 고정한다. 프로덕션의 여섯 제공자 어댑터가 전부 그 팩토리를 부른다. 참을 넣는 코드가 저장소에 없다.
사고 조사가 누가 어떤 목적으로 어떤 접촉점의 평문을 열었는지 물으면, 이메일 주소와 전화번호와 디바이스 토큰의 복호화는 아무 흔적도 내놓지 못한다. 타입 서명만 흔적이 남는 것처럼 읽힌다.
감사 설비가 없어서가 아니다. 포트도 기록 구현도 저장소에 있고, 같은 리프의 회전기가 그 포트를 주입받아 회전 사건을 남긴다. 관리자 평면도 쓴다.
판정은 P2 다.
수정은 복호화 구현에 감사 포트를 주입하고 노출 시점에 기록하는 것이다. 사건에는 목적 코드와 행위자 참조와 키 식별자와 유형만 넣고 평문과 지문은 넣지 않는다. 감사 포트 자바독이 원시 주소를 절대 싣지 않는다고 적어 둔 그대로다. 감사 필요가 거짓인 배달 경로까지 표본으로 남길지는 그다음 결정이다.
검증 환경
OpenJDK : 21.0.12 확인 방식 : 구현 본문 확인, 생성 지점과 접근자 전수 계수, 실행 탐침 소스 수정 : x
재현 조건
- 접근 문맥 레코드와 포트의 자바독을 읽는다.
- 유일한 구현의 복호화 메서드를 읽고, 문맥을 언급하는 줄을 센다.
- 저장소 전체에서 이 레코드를 만드는 곳을 센다.
- 그 팩토리가 세 필드에 무엇을 넣는지 확인한다.
- 세 필드 접근자를 저장소 전체에서 검색하고, 동명 접근자가 다른 타입의 것인지 확인한다.
- 복호화기를 만들어 문맥을 바꿔 가며 같은 값을 복호화한다.
- 복호화기가 어떤 협력자를 들고 있는지 반사로 확인한다.
- 감사 포트와 그 구현이 저장소에 있는지 확인한다.
본문
평문 연락처를 복호화하는 포트가 접근 문맥을 함께 받는다.
두 번 선언되는 성질
:::evidence key="a13-f003-accesscontext" alt="접근 문맥 레코드 전체와 그것을 받는 포트의 자바독, 유일한 구현의 복호화 메서드 전체, 저장소 전체에서 이 레코드를 만드는 곳, 세 필드 접근자의 호출 수와 그중 잡히는 곳의 목록, 그리고 감사 포트와 그 구현이 저장소에 있다는 것을 출력한 터미널 기록." caption="레코드 자바독이 모든 노출은 감사 가능하다고 적고 포트 자바독도 같은 말을 한다 · 복호화 메서드에서 문맥은 널 검사에만 나온다 · 레코드를 만드는 길은 감사 필요를 거짓으로 고정한 팩토리 하나뿐 · 목적 코드와 감사 필요 접근자 호출은 0 — 63줄 · exit 0" zoom="true" :::
5:/** Why a plaintext contact point is being revealed. Every reveal is auditable. */
6:public record AccessContext(String purposeCode, String actorRef, boolean auditRequired) {
11: /** Decrypt a value for an audited purpose. */
12: ContactPointValue reveal(ProtectedContactPoint protectedValue, AccessContext context);
유일한 구현이 그것을 버린다
86: public ContactPointValue reveal(ProtectedContactPoint protectedValue, AccessContext context) {
87: Objects.requireNonNull(protectedValue, "protectedValue");
88: Objects.requireNonNull(context, "context");
89: SecretKeyMaterial key = keys.keyById(protectedValue.keyId());
90: byte[] plaintext =
91: decrypt(
92: key,
93: protectedValue.nonce(),
94: associatedData(protectedValue.type()),
95: protectedValue.ciphertext());
96: return parse(protectedValue.type(), new String(plaintext, StandardCharsets.UTF_8));
97: }
문맥이 마지막으로 나오는 줄은 88 이다.
문맥을 바꿔 넣어 보면
:::evidence key="a13-f003-accesscontext-reveal" alt="복호화기를 만들어 접근 문맥을 셋으로 바꿔 가며 같은 값을 복호화한 결과, 복호화기의 생성자 인자와 필드를 반사로 뽑은 목록, 그리고 dispatch 팩토리가 세 필드에 넣는 값을 출력한 터미널 기록." caption="감사 필요를 참으로 두고 목적 코드를 바꿔도 돌아오는 값이 같다 · 복호화기가 들고 있는 것은 키 제공자와 난수뿐이다 · dispatch 팩토리는 감사 필요를 거짓으로 고정한다 — 17줄 · exit 0" zoom="true" :::
[문맥을 바꿔 가며 같은 값을 복호화한다]
purposeCode=DISPATCH auditRequired=false -> EmailAddress
purposeCode=BREAK_GLASS auditRequired=true -> EmailAddress
purposeCode=SUPPORT_LOOKUP auditRequired=true -> EmailAddress
감사 필요를 참으로 두어도 달라지는 것이 없다. 보낼 곳이 없기 때문이다.
[복호화기가 들고 있는 협력자]
생성자 인자 : [SecretMaterialProvider]
생성자 인자 : [SecretMaterialProvider, SecureRandom]
필드 CIPHER : String
필드 HMAC : String
필드 KEY_ALGORITHM : String
필드 keys : SecretMaterialProvider
필드 random : SecureRandom
참을 넣는 코드가 없다
application/notification/platform/security/AccessContext.java:17: public static AccessContext dispatch(String providerProfileId) {
application/notification/platform/security/AccessContext.java:18: return new AccessContext("DISPATCH", providerProfileId, false);
adapter/outbound/notification/platform/security/AesGcmContactPointProtectorTest.java:27: assertThat(protector.reveal(first, AccessContext.dispatch("ses-primary"))).isEqualTo(value);
adapter/outbound/notification/platform/security/AesGcmContactPointProtectorTest.java:65: assertThatThrownBy(() -> protector.reveal(moved, AccessContext.dispatch("ses-primary")))
adapter/outbound/notification/platform/security/AesGcmContactPointProtectorTest.java:86: assertThatThrownBy(() -> protector.reveal(rotated, AccessContext.dispatch("ses-primary")))
adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapter.java:115: AccessContext.dispatch(submission.profile().profileId().value()));
adapter/outbound/notification/platform/provider/webpush/WebPushNotificationProviderAdapter.java:90: AccessContext.dispatch(submission.profile().profileId().value()));
adapter/outbound/notification/platform/provider/smtp/SmtpNotificationProviderAdapter.java:90: AccessContext.dispatch(submission.profile().profileId().value()));
adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapter.java:86: AccessContext.dispatch(submission.profile().profileId().value()));
adapter/outbound/notification/platform/provider/fcm/FcmBatchCoordinator.java:70: AccessContext.dispatch(submission.profile().profileId().value()));
adapter/outbound/notification/platform/provider/apns/ApnsNotificationProviderAdapter.java:84: AccessContext.dispatch(submission.profile().profileId().value()));
이 레코드를 만드는 표현은 팩토리 안의 new AccessContext(...) 하나뿐이다. 나머지 아홉 건은 전부 그 팩토리 호출이고, 그중 셋은 테스트다.
[dispatch 팩토리가 정하는 값]
purposeCode=DISPATCH actorRef=ses-primary auditRequired=false
읽는 코드도 없다
purposeCode : 0
actorRef : 7
auditRequired : 0
행위자 참조가 잡히는 일곱 건은 전부 다른 타입이다.
application/notification/platform/admin/NotificationAdminApplicationService.java:138: actor.actorRef(),
application/notification/platform/admin/NotificationAdminApplicationService.java:189: actor.actorRef(),
application/notification/platform/admin/NotificationAdminApplicationService.java:267: actor.actorRef(),
application/notification/platform/admin/NotificationAdminApplicationService.java:321: actor.actorRef(),
adapter/outbound/notification/platform/observation/LoggingNotificationAudit.java:34: event.actorRef(),
adapter/outbound/persistence/notification/platform/JpaAdminOperationStore.java:44: actor.actorRef(),
adapter/outbound/persistence/notification/platform/JpaAdminOperationStore.java:101: actor.actorRef(),
관리자 행위자와 알림 감사 사건이다. 접근 문맥의 것이 아니다.
감사 포트와 기록 구현은 이미 있다
application/notification/platform/observation/NotificationAuditPort.java:4:public interface NotificationAuditPort {
adapter/outbound/notification/platform/dispatch/ProviderRuntimeRegistryTest.java:183: private static final class RecordingAudit implements NotificationAuditPort {
adapter/outbound/notification/platform/observation/LoggingNotificationAudit.java:23: implements NotificationAuditPort, NotificationSecurityAuditPort {
포트와 기록 구현이 있고, 같은 리프의 ProviderRuntimeRotator 가 그것을 주입받아 회전 사건을 남긴다. 빠진 것은 노출 경로의 연결 하나다.
확인하지 못한 것
과거에 그 문맥을 읽는 코드가 있었는지 이력에서 확인하지 않았다.