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 | a10-f006-requireidentifier | 지워도 test가 초록인 검사가 셋이다 | caching-and-redis | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:a10-f006-requireidentifier | 2026-09-02 | case-a10-f006-requireidentifier.body.md |
|
|
|
지워도 test가 초록인 검사가 셋이다
식별자 검증이 다섯 겹으로 보인다. 그중 둘은 앞선 문자 클래스 검사가 이미 걸러내 도달하지 않고, 하나는 도달하지만 그것을 겨냥한 test 입력이 뒤 검사에도 걸린다. 셋 다 지워도 test 는 초록이다.
관계
- 커버리지 gate 둘이 나란히 있고, 하나는 발화할 수 없다 두 사례 모두 조건이 성립할 수 없어 그 분기가 실행되지 않는다.
- 그 속성을 보일 수 없는 대역 위에서 통과한 테스트는 증인이 아니다 예외 타입만 보는 단언은 어느 검사가 던졌는지 구분하지 않는다.
- 상한을 주입받는 자리는 있고 주입하는 곳은 없다 같은 리프의 다른 검증 사례다.
문제
식별자 검증에 검사가 다섯 있다. 문자 클래스 하나와 구체적 형태 넷이다. 메일 주소, 웹 토큰, 국제 전화번호, 인증 재료 접두다.
각 검사가 실제로 발화하는지, 그리고 발화한다면 어느 test 가 그것을 붙들고 있는지 확인했다.
결론
문자 클래스는 첫 문자로 영숫자를 요구하고 이후 문자로 영숫자와 점과 물결과 밑줄과 붙임표를 허용한다. 길이는 128 이하다.
거기에 골뱅이도 더하기도 없다.
그래서 메일 분기는 도달하지 않는다. 골뱅이를 포함한 값은 첫 검사에서 탈락한다. 전화 분기도 도달하지 않는다. 국제 전화번호 패턴이 반드시 더하기로 시작하는데 더하기는 첫 문자로도 이후 문자로도 허용되지 않는다.
실행으로 확인했다. 두 형태를 넣으면 돌아오는 메시지가 문자 클래스 메시지다. 무작위 입력 20만 개에서도 이 두 검사에서 갈린 값이 하나도 없다.
웹 토큰 분기는 도달한다. 웹 토큰 패턴이 쓰는 문자를 문자 클래스가 모두 허용하므로, 26자에서 128자 사이이고 영숫자로 시작하는 토큰 형태는 첫 검사를 통과해 전용 검사에 닿는다.
다만 그것이 잡는 것은 진짜 토큰이 아니다. 진짜 토큰은 늘 같은 세 글자로 시작하고 길이도 128자를 넘긴다. 그런 값은 접두 검사나 문자 클래스가 먼저 잡는다. 혼자 걸리는 값은 토큰 흉내를 낸 합성 문자열뿐이다.
그리고 그것을 겨냥한 test 입력 하나가 다음 검사에도 걸린다. 그 값이 eyJ 로 시작해서 접두 검사가 같은 타입의 예외를 낸다. 웹 토큰 분기를 지워도 그 test 는 초록이다.
남는 둘은 붙들려 있다. 문자 클래스 검사는 128자 초과 입력과 구분자 주입을 거부하는 test 가 붙들고, 접두 검사는 웹 토큰 형태가 아닌 두 입력이 붙든다. 둘 중 하나를 지우면 대응 test 가 빨개진다.
저 네 메시지는 저장소에서 던지는 자리에만 있다. 단언하는 곳이 없다.
판정은 P3 이다.
거부는 그대로다. 세 형태는 여전히 전부 거부된다.
잃는 것은 둘이다. 운영자가 받는 메시지가 구체 형태에서 문자 클래스로 내려앉는다. 그리고 세 분기가 test 에 붙들리지 않은 채 검증이 다섯 겹인 것처럼 보이게 만든다.
원본은 두 분기를 지우고 문자 클래스 메시지에 그 의도를 포함시키거나, 검사 순서를 뒤집어 구체적 형태를 먼저 판정하는 수정을 제안했다. 뒤집는 쪽을 고르면 세 분기가 전부 발화한다.
검증 환경
OpenJDK : 21.0.12 확인 방식 : 문자 클래스와 후속 분기 대조, 실행 탐침, test 단언 대상 확인 소스 수정 : x
재현 조건
- 식별자 검증의 다섯 검사를 순서대로 읽는다.
- 각 검사가 쓰는 패턴 넷을 읽는다.
- 각 형태 검사가 요구하는 필수 문자가 문자 클래스 안에 있는지 본다.
- 이 메서드에 값을 넣는 test 를 모아 무엇을 단언하는지 확인한다.
- 그 입력들을 그대로 넣고 실제로 돌아오는 메시지를 본다.
- 각 입력이 다섯 중 어느 검사에 걸리는지 전부 표시한다.
- 웹 토큰 분기에만 걸리는 값과, 실제 크기의 토큰을 각각 넣어 본다.
- 무작위 입력으로 각 검사에서 갈린 수를 센다.
- 저장소 전체에서 네 메시지가 나오는 곳을 센다.
본문
검사는 아래 순서로 돈다.
다섯 검사
:::evidence key="a10-f006-requireidentifier" alt="식별자 검증 메서드의 다섯 검사 전체와 그 검사들이 쓰는 정규식 넷, 이 메서드에 값을 넣는 test 다섯 개가 무엇을 단언하는지, 그리고 저장소 전체에서 네 예외 메시지가 나오는 곳을 파일 형식 제한 없이 센 터미널 기록." caption="검사 다섯은 문자 클래스·메일·웹 토큰·전화·접두 순 · test 다섯 개의 단언은 모두 예외 타입뿐 · 네 메시지는 던지는 자리 넷에만 있고 단언하는 곳이 없다 — 78줄 · exit 0" zoom="true" :::
if (value == null || !IDENTIFIER.matcher(value).matches()) {
throw new IllegalArgumentException(
"identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a"
+ " key separator");
}
String lowerCase = value.toLowerCase(Locale.ROOT);
if (value.indexOf('@') >= 0) {
throw new IllegalArgumentException("identifier must not contain a mail address");
}
if (JSON_WEB_TOKEN.matcher(value).matches()) {
throw new IllegalArgumentException("identifier must not contain a JSON web token");
}
if (INTERNATIONAL_PHONE.matcher(value).matches()) {
throw new IllegalArgumentException("identifier must not contain a phone number");
}
if (lowerCase.startsWith("bearer") || lowerCase.startsWith("eyj")) {
throw new IllegalArgumentException("identifier must not contain authentication material");
}
첫 검사가 쓰는 문자 클래스에는 골뱅이도 더하기도 없다.
private static final Pattern IDENTIFIER = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$");
private static final Pattern JSON_WEB_TOKEN =
Pattern.compile("^[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}$");
private static final Pattern INTERNATIONAL_PHONE = Pattern.compile("^\\+\\d[\\d.~-]{7,}$");
넣어 보면 어느 메시지가 오는가
:::evidence key="a10-f006-requireidentifier-branch" alt="test 가 쓰는 여섯 입력을 실제 검증 메서드에 넣어 돌아온 메시지와, 각 입력이 다섯 검사 중 어디에 걸리는지 전부 표시한 표. 웹 토큰 분기에만 걸리는 합성 값, 밑줄로 시작하는 값, 실제 크기의 토큰. 그리고 무작위 입력 20만 개로 옮겨 적은 정규식과 실제 메시지를 대조하고 각 검사에서 갈린 수를 함께 센 터미널 기록." caption="메일·전화 입력이 받는 메시지는 문자 클래스 메시지 · 웹 토큰 test 입력은 접두 검사에도 걸리고 실제 크기 토큰은 문자 클래스에서 먼저 걸린다 · 무작위 20만 개에서 메일·전화 검사에서 갈린 입력은 0 — 27줄 · exit 0" zoom="true" :::
입력 길이 걸리는 검사 전부 | requireIdentifier 가 낸 메시지
test: 메일 18 문자클래스 메일 | identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a key separator
test: 전화 13 문자클래스 전화 | identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a key separator
test: eyj 20 접두 | identifier must not contain authentication material
test: bearer 19 접두 | identifier must not contain authentication material
test: JWT 57 JWT 접두 | identifier must not contain a JSON web token
test: 129자 129 문자클래스 | identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a key separator
메일과 전화 입력은 전용 검사 앞에서 이미 걸린다. 문자 클래스가 골뱅이와 더하기를 빼 놓았으니 그 둘에 도달할 값 자체가 없다.
웹 토큰 입력은 전용 검사에 닿는다. 다만 같은 값이 다음 검사에도 걸린다 — eyJ 로 시작하기 때문이다.
웹 토큰 분기가 혼자 잡는 것
합성 JWT 26자 26 JWT | identifier must not contain a JSON web token
_로 시작 26 문자클래스 JWT | identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a key separator
실제 크기 JWT 238 문자클래스 JWT 접두 | identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a key separator
혼자 걸리려면 26자에서 128자 사이이고 영숫자로 시작하며 eyJ 로도 bearer 로도 시작하지 않아야 한다. 실제 토큰은 헤더가 {" 로 시작해 base64url 로 늘 eyJ 가 되고, 길이도 128자를 넘기 일쑤다. 그러니 이 분기가 혼자 잡는 것은 토큰을 닮은 합성 값이다.
어느 검사를 지우면 test 가 빨개지는가
웹 토큰 분기는 일을 하지만 붙들고 있는 test 가 없다. 저 test 입력은 분기를 지워도 접두 검사가 같은 타입의 예외를 낸다.
문자 클래스 검사와 접두 검사는 다르다. 129자 입력과 1:2 는 문자 클래스 검사가 없으면 아무 검사에도 안 걸리고, eyJhbGciOiJIUzI1NiJ9 와 bearer-abcdefabcdef 는 접두 검사 말고 걸리는 데가 없다.
왜 test 가 이것을 못 잡는가
assertThatThrownBy(() -> new RedisKeyName("user", "person@example.com"))
.isInstanceOf(IllegalArgumentException.class);
단언 대상이 예외 타입뿐이다. 어느 검사가 던졌는지 보지 않는다.
sdk/api/key/RedisKeyRules.java:67: throw new IllegalArgumentException("identifier must not contain a mail address");
sdk/api/key/RedisKeyRules.java:70: throw new IllegalArgumentException("identifier must not contain a JSON web token");
sdk/api/key/RedisKeyRules.java:73: throw new IllegalArgumentException("identifier must not contain a phone number");
sdk/api/key/RedisKeyRules.java:76: throw new IllegalArgumentException("identifier must not contain authentication material");
저장소 전체를 파일 형식 제한 없이 훑어도 네 메시지가 나오는 곳은 던지는 자리 넷뿐이다.
옮겨 적은 정규식을 믿어도 되는가
표의 「걸리는 검사」 열은 소스에서 옮겨 적은 정규식으로 계산한다. 그 사본이 실제와 같은지 무작위 입력으로 대조했다.
200000 / 200000 일치
문자클래스 에서 갈린 입력 144166
메일 에서 갈린 입력 0
JWT 에서 갈린 입력 23462
전화 에서 갈린 입력 0
접두 에서 갈린 입력 30843
통과 1529
문자 클래스와 웹 토큰과 접두는 각각 수만 번씩 갈렸다. 메일과 전화는 0 인데, 그게 이 기록의 결론이다. 도달할 값이 없으니 대조할 방법도 없다.
잃는 것
거부는 그대로다. 세 형태 모두 거부된다.
운영자가 보는 메시지가 달라진다. 메일 주소를 담지 말라는 문장 대신 문자 클래스 문장이 온다.
그리고 세 분기가 검증을 다섯 겹처럼 보이게 만든다.
확인하지 못한 것
세 분기를 실제로 지운 빌드로 전체 test 를 돌리지 않았다. 소스를 고치지 않는 것이 이 작업의 조건이다. 대신 각 입력이 걸리는 검사를 전부 표시해, 지운 뒤에도 같은 타입의 예외를 낼 검사가 남는지 확인했다.
분기와 함께 쓰이지 않게 되는 패턴 필드까지 지운 빌드가 경고 없이 컴파일되는지도 확인하지 못했다.