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 | analysis-finding-a05-f001 | 추정식의 여유분이 상수라서 인코드가 내준 세 크기를 디코드가 거절한다 | multitenancy-isolation | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:analysis-finding-a05-f001 | 2026-09-04 | case-analysis-finding-a05-f001.body.md |
|
|
|
추정식의 여유분이 상수라서 인코드가 내준 세 크기를 디코드가 거절한다
SignedJsonCursorCodec:127 의 encodedLength / 4 * 3 + 3 은 나눗셈이 버린 그룹을 상수 3 으로 메운다. 실제로 필요한 여유는 인코딩 길이를 4 로 나눈 나머지에 따라 3 이거나 2 이거나 1 이라서, 3 이 필요한 자리에서만 추정값이 상한을 넘고 2046·2047·2048 바이트가 인코드는 통과하고 디코드에서 막힌다.
관계
- 서명된 커서의 구조와 검증 순서 이 추정이 놓인 검증 순서다. 크기 사전 검사가 서명 확인보다 앞에 있다.
- 이진 승인 코덱은 decode 후 재인코딩이 원본과 같아야 한다 이 사례가 어기는 왕복 성질이다. 재인코딩까지 가지도 못하고 디코드가 먼저 막는다.
- 커서 서명 검증이 길이·상수시간·순서를 전부 지켜야 했던 이유 같은 코덱의 검증 순서를 다룬 사례다.
문제
디코드는 페이로드를 풀기 전에 크기를 추정해 상한을 넘으면 거절한다. 큰 토큰이 먼저 메모리에 풀리는 것을 막기 위해서다.
인코드도 같은 상한을 쓴다. 두 검사가 같은 집합을 가르는지 확인했다.
결론
추정식의 여유분이 상수다. L / 4 가 마지막 그룹을 버리므로 그만큼을 메워야 하는데, 메울 양이 L % 4 에 따라 셋 중 하나로 달라진다. 코드는 그중 가장 큰 값을 언제나 쓴다.
프로브가 두 인코딩을 나란히 계산한 표가 그 주기를 보여 준다. 2043 부터 2049 까지 무패딩 초과분이 3, 2, 1, 3, 2, 1, 3 으로 돈다. 초과가 3 인 자리에서만 추정값이 2049 가 되어 2048 을 넘는다.
그래서 경계가 셋이다. 2046 바이트는 무패딩 인코딩이 2728 자이고 나머지가 0 이라 추정식이 2049 를 낸다. 2047 과 2048 도 같은 값을 받는다. 그 아래 크기들은 2046 을 받아 상한 안에 든다.
패딩은 원인이 아니다. 같은 표의 패딩 열을 보면 2044 와 2045 도 2049 를 받는다. 그 인코더를 택했다면 오늘 통과하는 두 크기까지 함께 막혔을 것이다. 2046 바이트는 3 의 배수여서 두 인코딩의 길이가 아예 같다.
두 주석이 서로 다르게 적는다. :125 자바독은 이 값이 최대치라고 적어 옳고, :103~:104 인라인 주석은 정확히 한정한다고 적어 틀렸다. 설계 의도는 절대 과소평가하지 않는 보수적 사전 검사였고, 실패한 것은 건전성이 아니라 정밀도다.
인코드 쪽 주석은 큰 페이로드를 막는 까닭을 이렇게 적는다 — 되돌려 받지 못할 토큰은 다음 페이지 요청을 깨뜨린다. 같은 까닭이 이 세 크기에도 걸리는데 인코드는 통과시킨다.
이 코덱은 프로덕션에 배선돼 있지 않다. 생성자를 부르는 자리가 시험 소스 세트에만 있고, 페이로드를 JSON 으로 옮길 구현체도 main 에 없다. 웹과 GraphQL 과 Mongo 쪽 페이지네이션은 각자 다른 코덱을 쓰며 그 셋 중 이 추정식을 가진 파일이 없다.
시험은 추정식에 도달한다. SignedJsonCursorCodecTest:168 이 상한을 훨씬 넘는 위조 구간으로 사전 검사를 발화시킨다. 빠진 것은 경계 바로 옆에서 왕복을 단언하는 시험이고 그 수가 0 이다.
검증 환경
OpenJDK : 21.0.12 Gradle : 9.0.0 확인 방식 : 인코더·디코더 선언과 두 상한 인용, 인코드의 크기 검사와 그 주석 인용, 디코드의 사전 추정과 추정식과 두 주석 인용, 그 식을 부르는 자리 전수, 이 코덱을 만드는 자리와 페이로드 코덱 구현 계수와 실제 배선된 코덱 셋의 추정식 사용 여부, 경계 값을 쓰는 시험 줄 계수와 추정식에 도달하는 시험 인용, 같은 식을 무패딩과 패딩 인코딩에 각각 적용한 표 계산, 저장소 클래스를 그대로 써서 여섯 크기를 인코드한 뒤 곧바로 디코드 소스 수정 : x
재현 조건
- 인코더와 디코더 선언, 두 상한 상수, 인코드의 크기 검사와 그 주석을 인용한다.
- 디코드의 사전 추정 자리와 추정식, 그리고 그것을 설명하는 주석 둘을 함께 인용한다.
- 이 코덱을 만드는 자리를 소스 세트별로 세고, 페이로드 코덱을 구현한 main 파일과 실제 배선된 코덱을 확인한다.
- 2043 부터 2049 까지 무패딩 길이와 패딩 길이를 각각 계산하고, 같은 식을 둘에 적용해 실제 최대치와의 차를 표로 만든다.
- 페이로드 바이트 수를 그대로 통제하는 코덱을 붙여 여섯 크기를 인코드하고 곧바로 디코드한다.
- 경계 값을 쓰는 시험 줄을 세고, 추정식에 도달하는 시험과 왕복을 단언하는 시험을 나눠 확인한다.
본문
SignedJsonCursorCodec 은 커서를 JSON 으로 만들어 서명하고 Base64URL 로 인코딩한다. 디코드는 서명을 확인하기 전에 페이로드 크기를 먼저 거른다.
여유분이 상수인 추정식
:::evidence key="analysis-finding-a05-f001" alt="저장소 루트에서 돌린 정적 검색과 왕복 프로브의 출력 115줄. 먼저 SignedJsonCursorCodec 3738번 줄의 인코더·디코더 선언과 5358번 줄의 두 상한, 6372번 줄의 encode 크기 검사와 그 주석, 103108번 줄의 decode 사전 추정, 125128번 줄의 추정식이 차례로 실린다. 그 식을 부르는 자리는 106번 줄 하나다. 이어서 이 코덱이 프로덕션에 배선되는지가 나온다. new SignedJsonCursorCodec 이 나오는 여섯 자리가 모두 시험이고, CursorPayloadCodec 을 구현한다고 선언한 main 파일이 0 개이며, 실제로 배선된 커서 코덱은 HmacGraphQlCursorCodec 과 HmacWebCursorCodec 과 MongoKeysetCursorCodec 셋인데 그 셋은 같은 추정식을 쓰지 않는다. 그 아래에 두 주석이 나란히 실린다. 125번 자바독은 이 값이 최대치라고 적고 103104번 인라인 주석은 정확히 한정한다고 적는다. 다음으로 경계 값을 쓰는 시험을 세면 2044·2045·2046·2047 이 0 개이고 2048 이 17 개 2049 가 1 개다. 추정식에 실제로 도달하는 시험은 SignedJsonCursorCodecTest 166~176번 줄인데 3000 자짜리 위조 구간을 넣어 payload exceeds 를 확인하는 것이고, 왕복을 상한 근처에서 단언하는 시험은 0 개이며 이 코덱을 만드는 자리는 test 소스 세트뿐이다. 마지막으로 프로브가 실린다. 먼저 같은 식을 두 인코딩에 적용한 표가 나오는데 n 이 2043 부터 2049 까지일 때 무패딩 길이와 그 추정값, 패딩 길이와 그 추정값, 실제 최대치, 무패딩 초과분이 함께 나온다. 초과분은 3, 2, 1 이 반복되고 패딩 쪽 추정값이 무패딩 쪽보다 크거나 같다. 이어서 같은 코덱 인스턴스로 여섯 크기를 왕복시킨 결과가 나온다. 2044 와 2045 바이트는 인코드도 디코드도 성공하고 추정식이 2046 을 낸다. 2046·2047·2048 바이트는 인코드는 성공하는데 디코드가 cursor payload exceeds the maximum size 로 거절하고 추정식이 2049 를 낸다. 2049 바이트는 인코드 자체가 거절한다." caption="인코더·디코더 선언과 두 상한 · encode 검사와 decode 사전 추정과 추정식 · 이 코덱을 만드는 자리가 전부 시험이라는 것과 실제 배선된 세 코덱 · 서로 다르게 적는 두 주석 · 경계 값을 쓰는 시험 계수와 추정식에 도달하는 시험 · 같은 식을 두 인코딩에 적용한 표와 여섯 크기 왕복 결과 — 115줄 · exit 0" zoom="true"
:::
:126~:128 의 decodedLengthOf 는 encodedLength / 4 * 3 + 3 을 돌려준다.
L / 4 는 정수 나눗셈이라 마지막 그룹을 버린다. 그 그룹이 디코딩하는 바이트 수는 남는 문자 수에 달려 있다 — L % 4 가 2 면 1 바이트, 3 이면 2 바이트, 0 이면 그룹이 온전하므로 버린 것이 3 바이트다.
그래서 필요한 여유는 3 이거나 2 이거나 1 이다. 코드는 언제나 3 을 더한다. 나머지가 2 나 3 인 자리에서는 추정값이 실제보다 1 이나 2 크고, 0 인 자리에서만 3 크다.
초과분이 3, 2, 1 로 돈다
프로브가 2043 부터 2049 까지를 표로 계산한다. 무패딩 초과분이 3, 2, 1, 3, 2, 1, 3 으로 반복된다.
상한이 2048 이므로 추정값이 2049 이상이 되는 자리만 막힌다. 그 자리는 초과가 3 인 곳, 즉 L % 4 == 0 인 곳이다.
n = 2046 이 그렇다. 무패딩 길이가 2728 이고 4 로 나누어떨어져서 추정식이 2049 를 낸다. 2047 과 2048 은 길이가 2730 과 2731 인데 /4*3 이 같은 2046 을 내므로 +3 이 붙어 역시 2049 가 된다.
2045 까지는 2046 이 나와 통과한다.
패딩은 원인이 아니다
같은 표의 패딩 열이 그것을 보여 준다. 패딩을 붙이면 2044 와 2045 의 길이가 2728 이 되어 추정식이 2049 를 낸다. 지금은 통과하는 두 크기가 막히게 된다.
n = 2046 은 3 의 배수라 패딩 인코딩과 무패딩 인코딩의 길이가 2728 로 같다. 이 경계를 만든 것은 인코더의 패딩 여부가 아니다.
패딩 인코더로 바꾸면 막히는 구간이 {2046, 2047, 2048} 에서 {2044 … 2048} 로 넓어진다.
두 검사가 가르는 집합
encode:66 은 json.length 를 MAX_PAYLOAD_BYTES 와 직접 견준다. 바이트 수 그대로다.
decode:106 은 decodedLengthOf(encodedPayloadLength) 를 같은 상한과 견준다. 추정값이다.
같은 코덱 인스턴스로 왕복시키니 2044 와 2045 는 양쪽을 통과하고, 2046·2047·2048 은 인코드만 통과하며, 2049 는 인코드가 거절한다.
:67~:68 주석은 인코드가 큰 페이로드를 막는 근거를 적는다. 코덱이 되돌려 줄 수 없는 크기의 토큰을 만들면 다음 페이지에서 실패하므로, 그것은 호출자의 잘못이 아니라 애플리케이션 자신의 결함이라는 것이다. 그 근거가 세 크기에서 그대로 성립하는데 인코드는 통과시킨다.
자바독은 맞고 인라인 주석은 틀리다
:125 자바독은 이 값을 "at most" 라고 적는다. 보수적 상한이라는 뜻이고 맞는 서술이다.
:103~:104 인라인 주석은 인코딩된 구간 길이가 디코딩 크기를 "exactly" 한정한다고 적는다. 표가 보여 주는 대로 초과분이 1 에서 3 사이로 움직이므로 그렇지 않다.
설계가 노린 것은 절대 과소평가하지 않는 사전 검사였다. 그 성질은 지켜졌다. 어긋난 것은 정밀도다.
이 코덱은 아직 아무도 쓰지 않는다
new SignedJsonCursorCodec 이 나오는 여섯 자리가 전부 SignedJsonCursorCodecTest 다. 이 코덱을 만드는 자리가 있는 소스 세트는 test 뿐이다.
CursorPayloadCodec 을 구현한다고 선언한 main 파일도 0 개다. 페이로드를 JSON 으로 만들 구현이 없다.
실제 페이지네이션은 HmacWebCursorCodec 과 HmacGraphQlCursorCodec 과 MongoKeysetCursorCodec 이 맡는다. 그 셋에서 같은 추정식을 쓰는 파일은 0 개다.
시험은 추정식에 닿지만 왕복을 묻지 않는다
SignedJsonCursorCodecTest:166~:172 는 3000 자짜리 위조 구간을 넣어 토큰 상한 아래·페이로드 상한 위에서 사전 검사가 먼저 터지는 것을 확인한다. 이 시험은 추정식에 실제로 도달한다.
없는 것은 상한 근처에서 decode(encode(x)) 를 단언하는 시험이고, 그런 시험이 0 개다. 2046 과 2047 을 쓰는 시험 줄도 각각 0 개다.
원문과 갈리는 자리
원문은 2046 부터 2048 까지가 인코드 성공·디코드 거절이라고 적었고 왕복 프로브가 그대로 재현한다.
원인 설명은 갈린다. 원문은 코덱이 패딩 없는 인코딩을 쓰기 때문에 추정이 어긋난다고 적었다. 표를 계산해 보면 패딩을 붙여도 같은 식이 어긋나고 오히려 막히는 구간이 넓어진다. 어긋나게 만드는 것은 인코딩 방식이 아니라 여유분이 상수라는 점이다.
원문이 적지 않은 것은 이 코덱이 프로덕션에 배선되지 않았다는 것과, :125 자바독이 옳게 적혀 있다는 것이다.
확인하지 못한 것
이 공개 타입을 실제로 가져다 쓴 프로젝트가 있는지는 저장소 밖의 일이라 알 수 없다.
나머지에 맞춰 여유분을 계산하도록 고쳤을 때 무엇이 깨지는지 빌드로 확인하지 않았다.
MAX_ENCODED_LENGTH 검사가 어떤 입력에서 발화하는지는 따져 보지 않았다.
토큰 전체 길이를 보는 상한이 실제 도달 가능한 값인지 계산하지 않았다.
등급에 대해
원본은 P2 다. 프로덕션 배선이 없고 실제 페이지네이션 코덱 셋에 같은 결함이 없으므로 오늘의 사고는 아니다. 다만 이 코덱은 공개 API 표면에 있으므로 채택자가 붙이는 순간 살아난다. 등급을 새로 매기지 않고 그 조건을 적어 둔다.