Skip to content

WICKET-7190 Unify and harden the cryptography subsystem - #1536

Draft
papegaaij wants to merge 7 commits into
document-security-modelfrom
crypt-unification
Draft

WICKET-7190 Unify and harden the cryptography subsystem#1536
papegaaij wants to merge 7 commits into
document-security-modelfrom
crypt-unification

Conversation

@papegaaij

Copy link
Copy Markdown
Contributor

Wicket had three overlapping crypt abstractions — org.apache.wicket.util.crypt.ICrypt (URL encryption, "remember me"), org.apache.wicket.pageStore.crypt.ICrypter (page store), and the ICryptFactory hierarchy — with different guarantees. Notably the page store default, DefaultCrypter, was AES-256-CBC: unauthenticated, so stored bytes were confidential but malleable, and tampered ciphertext still reached the deserializer.

This replaces all of it with one authenticated abstraction.

Base branch: this targets document-security-model (#1535), not master — the first commit edits SECURITY.md, which that branch introduces. It will retarget to master automatically once the base merges.

What changes

One interface. org.apache.wicket.core.util.crypt.ICrypt is now the single abstraction, used by the page store, CryptoMapper and the authentication cookie alike. It operates on byte[] with an optional associated-data parameter, and provides the URL-safe Base64 String layer as default methods. decrypt returns null on any failure — unknown scheme, failed authentication, malformed input — so callers uniformly treat undecryptable data as absent rather than catching exceptions.

Authenticated by default, and self-describing. SchemeCrypt writes marker(1) || scheme-payload, where the marker is the id of the ICryptScheme that produced the payload. Encryption always uses the configured scheme; decryption looks the marker up in a whitelist and refuses anything not on it. The marker is authenticated as associated data, so it cannot be altered to force a weaker scheme — together that gives downgrade protection, and a documented migration path (whitelist the old scheme while the new one becomes the encryption scheme, then drop it once data is rewritten).

Two shipped schemes, both AEAD:

  • AesGcmCryptScheme — JDK-native AES-256-GCM. The new default; no extra dependencies.
  • AesGcmSivCryptScheme — AES-256-GCM-SIV, nonce-misuse resistant. Requires Bouncy Castle.

Scheme and key source are separated. An ICryptScheme decides how data is encrypted and generates the keys it consumes; an ICryptFactory decides only where the key lives — KeyInSessionCryptFactory (per session, the default) or ApplicationKeyCryptFactory (application-wide, for stateless deployments).

Encrypted pages are bound to their page id. CryptingPageStore passes the page id as associated data, so a stored page cannot be replayed as a different one.

Behavioural fix outside the crypt package

AbstractFileUploadResource validated its signed upload settings by re-encrypting the expected values and comparing the resulting ciphertext. That only worked because the previous default was deterministic PBE. SchemeCrypt uses a fresh nonce per message, as an AEAD mode must, so the comparison could never succeed and every FileUploadToResourceField upload would have been rejected. It now decrypts the token and compares the settings it carries; the authentication tag is what makes the token unforgeable, and FileUploadToResourceFieldSecurityTest still rejects tampered limits.

Compatibility — this is a Wicket 11 API break

Removed with no replacement: SunJceCrypt, AbstractCrypt, TrivialCrypt, CryptFactoryCachingDecorator, AESCrypt, AbstractJceCrypt, KeyInSessionSunJceCryptFactory, AbstractKeyInSessionCryptFactory, DefaultCrypter, GCMSIVCrypter, ICrypter. NoCrypt, NoCryptFactory and ICryptFactory move to org.apache.wicket.core.util.crypt. OpenRewrite recipes for the moves and notes on the removals are in wicket-migration.

Existing ciphertext does not decrypt under the new schemes. In practice that means encrypted URLs and page store entries from a previous version are treated as expired, and "remember me" cookies are invalidated.

Testing

  • Full wicket-core-tests suite: 2331 tests, 0 failures.
  • Full reactor clean test-compile passes.
  • New coverage: SchemeCryptTest (19), CryptFactoryTest (3), plus extended CryptoMapperTest and CryptingPageStoreTest — round-trips, associated-data mismatch, whitelist refusal, marker tampering.

Known red: japicmp will fail on the removals above and needs a Wicket 11 baseline before CI is green.

🤖 Generated with Claude Code

papegaaij and others added 6 commits August 4, 2026 13:39
Replace the two disconnected crypt stacks (util.crypt.ICrypt and
pageStore.crypt.ICrypter) with a single authenticated, self-describing crypto
system in org.apache.wicket.core.util.crypt:

- ICryptScheme with AesGcmCryptScheme (JDK-native AES-256-GCM, the default) and
  AesGcmSivCryptScheme (AES-256-GCM-SIV, Bouncy Castle, optional). Ciphertext is
  prefixed with a one-byte scheme marker that is bound as AEAD associated data.
- SchemeCrypt selects the scheme by its marker against a configurable whitelist
  (downgrade protection), always encrypts with the strongest configured scheme,
  and returns null on any decryption failure.
- Key-source factories: KeyInSessionCryptFactory (per-session key, the default)
  and ApplicationKeyCryptFactory (application-wide key, used by remember-me).
- SecuritySettings owns the crypto policy (crypt scheme + whitelist); the page
  store, CryptoMapper and the remember-me cookie all use it.

This fixes the reported page-store weakness (unauthenticated AES-CBC, malleable
via CBC bit-flipping and vulnerable to a padding oracle) by making all
encryption authenticated, and removes the weak PBEWithMD5AndDES default that was
used for URLs and the authentication cookie.

Authenticated encryption uses a random nonce and is therefore non-deterministic.
CryptoMapper reuses, within a request, the ciphertext it decrypted so that a
regenerated URL matches the requested one; this avoids Wicket's URL-normalisation
infinite redirect while keeping random nonces (a nonce is only ever reused for
the identical plaintext).

Removes the obsolete implementations (SunJceCrypt, AbstractCrypt, TrivialCrypt,
CryptFactoryCachingDecorator, AESCrypt, AbstractJceCrypt,
KeyInSessionSunJceCryptFactory, AbstractKeyInSessionCryptFactory, DefaultCrypter,
GCMSIVCrypter, ICrypter) and adds a MigrateToWicket11 OpenRewrite recipe. Data
encrypted by Wicket 10 cannot be decrypted by Wicket 11 (clean break); the
marker/whitelist mechanism enables future migrations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… data

Expose associated data (AAD) in the crypt API and use it to bind each encrypted
page to its id:

- ICrypt gains encrypt(byte[], byte[] associatedData) and
  decrypt(byte[], byte[] associatedData); the no-arg overloads delegate with no
  associated data. SchemeCrypt authenticates marker || associatedData, so the
  no-associated-data path is unchanged and URL/cookie ciphertext is unaffected.
- CryptingPageStore passes the page id (4 big-endian bytes) as associated data,
  using the trusted id parameter on getPage and the server-assigned id on
  addPage. A blob stored for one id can no longer be substituted into another
  slot by a tamper-capable storage adversary: it fails authentication and is
  treated as a cache miss.

URLs (CryptoMapper) and the remember-me cookie bind no associated data: there is
no useful context to add (the session is redundant under a per-session key and
breaks URL sharing under an application-wide key; request-specific context would
break bookmarkability).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A factory cannot know what key material an encryption scheme needs, so key
generation is moved from the factories onto the scheme:

- ICryptScheme gains an abstract generateKey(SecureRandom). The scheme owns the
  key material; a factory only decides where the key lives (per session, global,
  externally supplied). All schemes sharing one SchemeCrypt must produce
  compatible keys, since existing ciphertext is decrypted with the current key
  during migration.
- New AbstractAesGcmCryptScheme carries everything the AES-256 GCM-family schemes
  share: the 256-bit AES key generation and the encrypt/decrypt flow (12-byte
  nonce, 128-bit tag, nonce||ciphertext||tag layout, marker authenticated as
  associated data). AesGcmCryptScheme and AesGcmSivCryptScheme now only supply
  id(), the Cipher and the AlgorithmParameterSpec, removing the duplication that
  existed between them.

All four call sites that previously hardcoded
CipherUtils.generateKey("AES", 256, ...) now delegate to the configured scheme:

- AbstractCryptFactory gains a protected generateKey(random) helper resolving the
  scheme from SecuritySettings; KeyInSessionCryptFactory uses it.
- ApplicationKeyCryptFactory(SecureRandom) generates its key lazily on first use
  (so the scheme is configured by then) and caches it; the (SecretKey)
  external-key constructor is unchanged.
- SecuritySettings.getAuthenticationStrategy() and CryptingPageStore both ask the
  scheme for the key.

Tests: SchemeCryptTest verifies generateKey yields a usable 256-bit AES key that
round-trips; CryptFactoryTest covers the lazy application-random-key path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "URLs encryption in detail" section still described the Wicket 10 crypt API
(some of it already stale before this work). Bring it in line with the redesign:

- ICrypt is shown from its real package org.apache.wicket.core.util.crypt with
  the byte-array + URL-safe-String signature and the null-on-failure contract.
- The default implementation is SchemeCrypt (self-describing, authenticated
  ciphertext), not SunJceCrypt/PBEWithMD5AndDES. Document the pluggable
  ICryptScheme, the default AesGcmCryptScheme (JDK-native AES-256-GCM), the
  scheme whitelist / downgrade protection and migration, and the AES-256-GCM-SIV
  opt-in (requires Bouncy Castle).
- The default factory is KeyInSessionCryptFactory (was KeyInSessionSunJceCrypt-
  Factory); the stateless note now points to ApplicationKeyCryptFactory.

Also drop the outdated warning in the URL chapter that the default cipher "might
not be strong enough for production": the default is now authenticated
AES-256-GCM with a per-session key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The signed upload settings introduced in 483b83c were validated by
re-encrypting the expected settings and comparing the resulting ciphertext
to the token supplied by the client. That only worked because the default
crypt at the time was deterministic PBE: the same plaintext always produced
the same ciphertext.

SchemeCrypt encrypts with a fresh random nonce per message, as an AEAD mode
must, so no two encryptions of the same settings agree and the comparison
never succeeds - every upload through FileUploadToResourceField was rejected.

Validate the token by decrypting it and comparing the settings it carries
instead. The authentication tag is what makes the token unforgeable, and
decryption returns null for anything tampered with, so the check keeps
rejecting a client that alters the limits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The redesigned crypt classes document what they guarantee, but not the limits
of that guarantee. Both points were made in the javadoc of the ICrypter
implementations this branch removes, and until now survived only in
StoreSettings#setEncrypted, the user guide and SECURITY.md - none of which a
reader lands on when they open CryptingPageStore or implement ICryptScheme.

Record on CryptingPageStore that the session-held key protects the stored
pages against a party who can read or write the underlying store, but not
against one who already controls the session, so the store is still trusted
storage. Note on ICryptScheme that a custom scheme returning unverified
plaintext instead of null removes the tamper detection callers rely on, and
point both at SECURITY.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Authenticated encryption draws a random nonce, so re-encrypting the same URL
produced different ciphertext every time. CryptoMapper worked around that with a
per-RequestCycle plaintext -> ciphertext memo: enough to keep URL normalisation
from redirecting endlessly, but it died with the request. Every page view
therefore handed the browser brand-new URLs for every JavaScript, CSS and image
resource. Those responses carry a one-year cache duration, and because the URL
itself changed the browser could not even issue a conditional request, so each
page view re-downloaded every resource; through CSS, whose body embeds resource
URLs, the effect compounded. Wicket 10 did not have this problem, as SunJceCrypt
derived its IV from a per-session salt and was thereby deterministic.

Add an explicit deterministic encryption path to the crypt API and use it for
URLs:

- ICrypt.encryptDeterministic and ICryptScheme.encryptDeterministic. Required
  rather than an opt-in with a random-nonce fallback, so that a scheme cannot
  silently reintroduce the redirect hazard. Both write the existing ciphertext
  format, so decryption is unchanged.
- AbstractAesGcmCryptScheme derives the nonce as
  HMAC(HMAC(key, "wicket-deterministic-nonce"), len(aad) || aad || plaintext)
  truncated to 96 bits, covering AES-256-GCM and AES-256-GCM-SIV alike.
- CryptoMapper encrypts with the deterministic path, which lets the entire memo
  go: ENCRYPTED_URL_CACHE, encryptString, rememberEncryption and
  getEncryptionMemo, along with the verification decrypt they required.

Determinism reveals that two ciphertexts encrypt equal plaintexts, and lets a
key holder confirm a guessed URL. With the default per-session key that stays
confined to a single session, and it is what Wicket 10 already did. The page
store, the "remember me" cookie and the upload token keep randomized nonces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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