Skip to content

feat(pairwise): add NewReversible, a reversible pairwise deriver - #649

Merged
osanderson merged 8 commits into
mainfrom
feat/pairwise-reversible
Oct 13, 2026
Merged

osanderson merged 8 commits into
mainfrom
feat/pairwise-reversible

Conversation

@osanderson

@osanderson osanderson commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Adds pairwise.NewReversible, the recommended pairwise deriver: it encrypts the internal subject into the pairwise sub, so the OP can map any sub it issued back to its end user without storing anything. JWT access tokens, id_token_hint/login_hint/claims sub values and resource servers all resolve through pairwise.Lookup, as with NewMapping.

Construction

SIV (Rogaway and Shrimpton, "A Provable-Security Treatment of the Key-Wrap Problem", EUROCRYPT 2006), assembled from the standard library (Go has no AES-SIV; the module has no dependencies):

A    = u32be(len(f)) || f, for f in ["fapigo ppid r1", issuer, sector]
P    = byte(len(subject)) || subject || zero padding to 152 bytes
IV   = HMAC-SHA-256(macKey, A || P)[:16]
C    = AES-256-CTR(encKey, IV, P)
body = base64url(IV || C), no padding        -- always 224 characters
  • Two independent 32-byte keys; wrong size, placeholder (one byte repeated) and equal keys are refused. Custody is declared as for NewHMAC.
  • Issuer and sector are associated data: a body only opens under the issuer and sector it was derived for.
  • Fixed-size plaintext: every body is the same length, hiding the subject's length. Internal subjects are limited to 151 bytes (pairwise.MaxReversibleSubjectLength, ErrSubjectTooLong); 224 characters plus a 16-character prefix stays within OIDC's 255.
  • Lookup refuses an issuer or sector longer than MaxInputLength (which no Input carries), then decodes strictly (exact length, canonical base64url), decrypts, and authenticates the full 16-byte IV in constant time before reading the plaintext. Any altered, truncated or foreign body is ErrSubjectNotFound, never another subject.

The docs state plainly that anyone holding both keys can map every pairwise sub ever issued back to its user, that every resource server given the keys shares that risk, and that the keys can't rotate without changing every sub. GETTING_STARTED now presents NewReversible as the recommended deriver, with NewMapping and NewHMAC as the alternatives.

Assurance

  • Independent vectors: pairwise/testdata/reversible_vectors.json (11 vectors: users, sectors, issuers, a 1-byte and a 151-byte subject, a second key pair, a non-ASCII UTF-8 issuer and sector, and a subject whose IV ends in 0xfffffffd so the counter carries out of its low 32-bit word, which a 32-bit-counter port fails) generated by reversible_vectors.py — Python's hmac/hashlib for the IV and the OpenSSL CLI for AES-256-CTR. The Go code reproduces every vector in both directions.
  • Fuzzing (each ≥60 s locally, now also in the scheduled fuzz workflow): round trip and cross-sector refusal; tampering with any byte; arbitrary bodies never panic and are never accepted.
  • Tests: the deriver contract, every single-bit flip of a body, a forged IV re-encrypted correctly (only a full 16-byte comparison rejects it), key refusals, the subject cap, cancellation, custody, the plaintext parser's refusals, and server.New → code exchange → refresh → resource server with JWT access tokens.
  • Mutation checks: 15 of 16 killed; the survivor is an equivalent mutant (the early body-length check, kept so an oversized string is never decoded).

Review follow-ups

  • Long internal subjects: server.NewSubjectID accepts up to 255 bytes, so a longer subject than the reversible deriver holds now fails a pairwise completion with a server_error that names the 151-byte limit (audited, no code issued), instead of an opaque derivation failure. The limit is exported as pairwise.MaxReversibleSubjectLength and documented on NewSubjectID, SubjectConfig and in GETTING_STARTED.
  • Deriver contract: the tampered-body case now changes a character to another within the base64url alphabet, so it reaches a Deriver's authentication or lookup instead of only its decoding; a test confirms it does for NewReversible.

Non-breaking: a new constructor; Derivation: pairwise.Reversible already required a pairwise.Lookup.

🤖 Generated with Claude Code

osanderson and others added 2 commits October 13, 2026 07:03
NewReversible encrypts the internal subject into a pairwise sub, so the
OP can map any sub it issued back to its end user without storing
anything: JWT access tokens, hints and resource servers all resolve
through pairwise.Lookup. It is the deriver the docs now recommend.

The construction is SIV (Rogaway and Shrimpton, EUROCRYPT 2006),
assembled from the standard library since Go has no AES-SIV and the
module has no dependencies: a synthetic IV, HMAC-SHA-256 under one key
over the length-prefixed domain, issuer and sector and the padded
plaintext, truncated to 16 bytes; then AES-256-CTR under a second key
from that IV. The plaintext is the subject's length, the subject and
zero padding to a fixed 152 bytes, so every body is 224 characters and
hides the subject's length; internal subjects are limited to 151 bytes
(ErrSubjectTooLong). Lookup authenticates before reading anything: a
body altered in any bit, or looked up under another issuer or sector,
is ErrSubjectNotFound, never another subject.

The keys are refused when they're the wrong size, a placeholder, or
equal, and declare custody as NewHMAC's do. Anyone holding both keys
can map every pairwise sub ever issued back to its user; the docs say
so, and that they can't rotate without changing every sub.

Known-answer vectors in pairwise/testdata/reversible_vectors.json come
from an independent reference (reversible_vectors.py: Python's
hmac/hashlib and the OpenSSL command-line tool for AES-256-CTR), and
three fuzz targets cover the round trip, tampering and arbitrary input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.62366% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
pairwise/reversible.go 94.66% 2 Missing and 2 partials ⚠️
pairwise/contract.go 90.90% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

osanderson and others added 6 commits October 13, 2026 07:09
…ses one

server.NewSubjectID accepts internal subjects of up to 255 bytes, but
pairwise.NewReversible holds at most 151, so a longer one failed a
pairwise completion with an opaque "failed to derive" server_error. The
error now names the limit, and the limit is exported as
pairwise.MaxReversibleSubjectLength (renamed from
ReversibleMaxSubjectLength, unreleased) and documented on
NewSubjectID, SubjectConfig and in GETTING_STARTED.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The contract's tampered-body case XORed one character with 0x01, which
for characters like A, z, - and _ leaves the base64url alphabet, so the
case only reached a Deriver's decoding. It now rotates the character
within the alphabet, so the altered body still decodes and the case
reaches the Deriver's own authentication or lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No Input carries an issuer or sector longer than MaxInputLength, so no
body was derived under one. Lookup now refuses them as not found before
computing over them, which also keeps the construction's 4-byte length
prefixes exact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two independently computed vectors: a non-ASCII issuer and sector
(length-prefixed as UTF-8 bytes), and a subject whose synthetic IV ends
in 0xfffffffd, found by an offline search, so AES-CTR over the 10-block
plaintext carries out of the IV's low 32-bit word and a port that
increments only a 32-bit counter fails it. A test keeps a carrying
vector in the set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Excludes pairwise/contract.go from analysis like storage/contract.go
(it is a reusable contract test suite), and **/testdata/** from
coverage (fixtures and the scripts that generate them); names the
repeated fixture values in the vector generator, which still produces
identical vectors; and inlines a compared-once test variable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@osanderson
osanderson merged commit 3acc813 into main Oct 13, 2026
19 checks passed
@osanderson
osanderson deleted the feat/pairwise-reversible branch October 13, 2026 00:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant