Skip to content

fix(crypto,session): full-length AEAD tags, canonical tokens, secret minimums, impersonation authorization - #82

Merged
ExorTek merged 16 commits into
masterfrom
fix/crypto-session-secrets
Oct 24, 2026
Merged

ExorTek merged 16 commits into
masterfrom
fix/crypto-session-secrets

Conversation

@ExorTek

@ExorTek ExorTek commented Oct 24, 2026

Copy link
Copy Markdown
Owner

What / why

Cipher, seal and session hardening. Each fix has a regression test that reproduced the problem.

@exortek/crypto

  • Truncated GCM tags verified. decryptSymmetric / decryptHybrid never pinned the tag length, so a 4-byte tag decrypted (2^32 forgery work instead of 2^128). The full 16-byte tag is now required and authTagLength pinned; IV length checked.
  • Malleable tokens. unseal / decryptFromString used lenient base64url decoding — t + '=', t + '\n', ' ' + t and a stray ! all unsealed. Only the canonical form is accepted now.
  • Weak seal secrets. seal(x, '') worked; secrets must be ≥ 32 bytes.
  • clockSkew: NaN disabled expiry → rejected. aad with CBC was silently ignored → rejected. encryptWithPassphrase accepted iterations: 1 → OWASP floor per digest. base58.decode was quadratic with no cap → 1024-character default limit. fingerprint treated shared references as cycles → fixed.

@exortek/session

  • Session and trusted-device secrets ≥ 32 bytes, checked at configuration time.
  • Impersonation had no authorization. Any valid session could call impersonate() once the feature was on. impersonationAuthorize is now required and decides every impersonation (FORBIDDEN on refusal); a per-call ttl can only shorten impersonationTtl.
  • Trusted-device cookies can be revoked server-side via revokedBefore(userId).
  • Memory store eviction at capacity is O(1) (72.1 → 2.6 µs per insert on a full 10k store); the Redis store skips the duplicate read on the rolling touch and parallelizes put.

Which package(s)?

  • @exortek/crypto
  • @exortek/session
  • Repo tooling (build, CI, docs site, lint/format) — web docs

Type of change

  • 🐛 Bug fix
  • 💥 Breaking change (existing behaviour changes — requires a major bump)
  • 📖 Documentation

Checklist

  • yarn verify passes locally (Redis integration suites skipped locally — CI runs them)
  • Tests added / updated for the change
  • Docs updated (web/content/crypto, web/content/session)
  • Changesets added — both major, with migration notes
  • Public API changes carry updated JSDoc

Additional notes

Independent of #78–#80. Not released on merge.

decryptSymmetric and decryptHybrid passed the caller's tag straight to
setAuthTag without pinning its length, so a 4-byte GCM tag verified
(Node only prints a deprecation warning). A forger then needs 2^32
attempts instead of 2^128.

The tag must now be exactly the algorithm's tag length (16 bytes for
GCM / ChaCha20-Poly1305) and authTagLength is pinned on the decipher;
IV length is checked too. A short tag is DECRYPT_FAILED.
unseal and decryptFromString decoded tokens with Buffer.from(…,
'base64url'), which skips characters outside the alphabet and ignores
padding. t + '=', t + '\n', ' ' + t and a stray '!' mid-token all
unsealed to the same value, so anything keyed on the token string (deny
lists, replay caches, dedupe sets) saw one token as many.

Both now accept only the canonical unpadded base64url form
(TOKEN_MALFORMED / INVALID_CIPHERTEXT otherwise).
seal / unseal only type-checked the secret, so seal(x, '') or a short
password-like secret worked — the HKDF input then provides no real
protection. Both now require ≥ 32 bytes per secret (every entry of a
rotation list included) and throw INVALID_ARGUMENT otherwise.

BREAKING CHANGE: secrets shorter than 32 bytes are rejected.
createSessionManager and createTrustedDeviceCookie only type-checked
their secret, so an empty string (or Buffer.alloc(0)) was accepted even
though the error text said non-empty, and cookies sealed under a short
secret are forgeable. Every secret entry must now be at least 32 bytes,
checked at configuration time.

BREAKING CHANGE: secrets shorter than 32 bytes are rejected.
…rink

impersonate() checked that the caller had a valid, non-impersonated
session — and nothing else. Any logged-in user of an app that turned on
impersonation: true could become any other user; the authorization was
left implicit. A per-call options.ttl could also exceed the configured
impersonationTtl.

- createSessionManager requires impersonationAuthorize({ admin,
  targetUserId, reason, req }) with impersonation: true; anything but
  true from it is the new FORBIDDEN (403)
- options.ttl must be positive and at most impersonationTtl

BREAKING CHANGE: impersonation: true without impersonationAuthorize
throws at configuration time.
A trusted-device cookie stayed valid until it expired (30 days, say)
wherever it had been copied — logout or a password change could not
withdraw it. createTrustedDeviceCookie takes an optional
revokedBefore(userId) → epoch ms; cookies issued before the returned
cutoff no longer verify, so "forget all devices" is one timestamp.
Once full, every insert into the memory session store ran a full sweep
and then scanned all sessions for the oldest anonymous one — O(n) per
insert, exactly when an anonymous-session flood fills it.

Anonymous sessions now live in their own insertion-ordered LRU, so
picking a victim is O(1), and the garbage sweep runs at most once a
second from the insert path (the background timer still runs). Eviction
order is unchanged: expired / revoked first, then the oldest anonymous,
then the oldest authenticated session.

10k-session store, insert at capacity: authenticated 72.1 → 2.6 µs,
anonymous 39.8 → 2.2 µs.
The rolling touch in verify() read the record with get() and then
update() read it again before writing — MGET, MGET, SET per sampled
request. update() now takes the record the caller already holds
(optional third argument; custom stores may ignore it) and the Redis
store skips the re-read. Revocations are separate tombstone keys, so
writing over that copy can never un-revoke a session.

put() also sends the record write and the user-index write together
instead of one after the other.
fingerprint() kept every visited object in its cycle set for the whole
walk, so an object reached twice through siblings ({ x: o, y: o }) was
reported as a cyclic reference and threw. The set now holds only the
current ancestors; real cycles are still rejected.
encryptSymmetric / decryptSymmetric accepted options.aad with
aes-256-cbc and silently dropped it — CBC has no tag to bind it to — so
callers believed a ciphertext was tied to a context it was not. AAD with
a non-AEAD algorithm is now INVALID_ARGUMENT.
unseal compared now > expiresAt + clockSkew * 1000. With clockSkew: NaN
(for example a mis-parsed config value) the comparison is always false,
so an expired token unsealed forever; Infinity did the same. clockSkew
must now be a non-negative finite number.
encryptWithPassphrase defaulted to a sound PBKDF2-SHA512 at 210,000
iterations but accepted any caller value, down to iterations: 1. New
tokens now require the OWASP floor for the chosen digest (SHA-512
210,000; SHA-256 / SHA-384 600,000). Decryption still honours whatever
the caller pins, so older low-iteration tokens stay readable.

BREAKING CHANGE: encryptWithPassphrase rejects iterations below the
floor.
base58.decode multiplies a BigInt per character, so its cost grows
quadratically (32k characters took 73 ms, and it keeps growing) with
no limit on attacker-supplied input. Inputs over 1024 characters are now
INVALID_ENCODING unless the caller raises options.maxLength.
…8 cap

Document the 32-byte seal secret minimum, canonical-only sealed /
encrypted strings, the refusal of truncated AEAD tags and of AAD on CBC,
the clockSkew contract, the PBKDF2 iteration floors for
encryptWithPassphrase and base58.decode's length cap.
…evocation

Document impersonationAuthorize (required) and FORBIDDEN, the per-call
impersonation ttl that can only shrink, the 32-byte secret minimum,
trusted-device revokedBefore and the optional current-record argument
to a store's update().
Copilot AI balanced review requested due to automatic review settings October 24, 2026 13:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@ExorTek
ExorTek merged commit ef623b3 into master Oct 24, 2026
3 checks passed
@ExorTek
ExorTek deleted the fix/crypto-session-secrets branch October 24, 2026 13:55
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.

2 participants