Skip to content

Opt-in application-layer encryption: secret-service-hardened + hardened-tpm2 - #62

Open
swiesend wants to merge 69 commits into
mainfrom
claude/secrets-encryption-design-daWnL
Open

Opt-in application-layer encryption: secret-service-hardened + hardened-tpm2#62
swiesend wants to merge 69 commits into
mainfrom
claude/secrets-encryption-design-daWnL

Conversation

@swiesend

@swiesend swiesend commented Apr 25, 2026

Copy link
Copy Markdown
Owner

Adds opt-in application-layer encryption on top of the classical Secret Service client, shipped as new separately-versioned artifacts. Core consumers are unaffected (no API change, no new transitive deps); consumers who want at-rest encryption of secret bodies opt into secret-service-hardened (and optionally -tpm2). Rebased onto main, so it also carries the released 3.0.0-alpha functional API and the recent lock/flaky-test fixes.

Architecture

  • Maven reactor split into three artifacts at one version, with the parent pom as a BOM:
    • de.swiesend:secret-service — JDK 17, the classical Secret Service 0.2 client (moved into a core/ submodule; GAV unchanged for downstream consumers).
    • de.swiesend:secret-service-hardened — JDK 21, the encryption layer (depends only on core + HKDF; BouncyCastle is requires static / reflective and optional).
    • de.swiesend:secret-service-hardened-tpm2 — JDK 21, optional TPM-sealed key material (classpath-only; Microsoft TSS.Java isn't a valid JPMS module).

Hardened layer

  • HardenedCollection decorator over a CollectionInterface: AES-256-GCM envelopes with HKDF-derived per-item DEKs. API: createItem / withSecret / withSecrets / matchesSecret (constant-time, plaintext-free) / rotateEpoch / migrateNonHardenedToHardened.
  • KeyMaterialProvider SPI (getPepper, getTotpSeed, mode, threatCoverage, close()), with EnvVarKeyMaterialProvider (CI/dev only, loud warning), FileKeyMaterialProvider, NoTotpKeyMaterialProvider, and Argon2KeyMaterialProvider (Argon2id pepper-stretching; EMBEDDED/INTERACTIVE/SENSITIVE profiles per RFC 9106).
  • HybridKem — X25519 always-on, optionally + ML-KEM-768 via the standard javax.crypto.KEM API (JDK 24 SunJCE or BouncyCastle 1.82 on JDK 21–23). The KEM shared secret is mixed into the DEK.
  • EpochKeystore — persists per-epoch keypairs as an encrypted item inside the wrapped collection; rotateEpoch destroys superseded epoch keys for forward secrecy.
  • HardenedHealthCheck (structured /healthz-style diagnostic + toMap), ThreatCoverage (per-class A/B/C/D Level ratings), HardenedStatus, and a one-line startup posture log.

Hardened-tpm2 layer

  • Tpm2KeyMaterialProvider.forPlatformTpm(...) / forSimulator(...) for a TPM-sealed pepper; Tpm2Provisioner CLI (--password-{stdin,env,fd,prompt}, --pepper-{stdin,env,fd}; no plaintext --password); versioned Tpm2SealedBlob (stable wire bytes, atomic mode-0600 write, group/other read refused); Tpm2Availability.isAvailable() preflight; TPM dictionary-attack lockout on the sealed object.
  • Tpm2GenerationAnchor — a TPM NV monotonic counter backing the anti-rollback anchor (below).

Security review fixes (in this branch)

  • Data-loss / correctness: capture the TOTP step once per createItem (STORED_STEP write race); EpochKeystore create-then-delete + generation-based load; rotateEpoch destroys all superseded epochs; LIVE_CODE ±1-step read tolerance; migration reads into a zeroable char[] (not String).
  • KEM: always-on KEM with a real classical X25519-only mode and honest kem_id labels; store the X25519 epoch public (keystore v2) so a loaded epoch is writable; graceful decode of unknown/old keystore formats.
  • Type-safety / fail-safe: KemId enum replaces bare byte constants; createItem returns Optional.empty() on crypto failure instead of throwing.
  • Anti-rollback: GenerationAnchor SPI so an attacker who re-introduces an older keystore is refused (fail-closed), backed by the TPM NV counter above.

Docs

  • docs/threat_models_and_mitigation.md (deployment threat-model & mitigation guide) and docs/usage_examples.md (worked core / hardened / TPM examples).

Testing

  • Hardened crypto suite (EpochKeystore, HybridKem, Envelope, HardenedCollection, GenerationAnchor) is daemon-free and green (60 tests); TPM tests are simulator-gated (skip cleanly without a TPM/simulator). Full reactor compiles; core runs against the live gnome-keyring.

All commits are signed and authored by the maintainer.

@swiesend
swiesend force-pushed the claude/secrets-encryption-design-daWnL branch from 7ddc48a to f9253cf Compare April 27, 2026 21:05
@swiesend
swiesend force-pushed the claude/secrets-encryption-design-daWnL branch 2 times, most recently from 279cab1 to ac0d064 Compare July 3, 2026 22:03
swiesend and others added 27 commits July 4, 2026 00:06
Introduces a new de.swiesend.secretservice.hardened package that decorates
a CollectionInterface with AES-256-GCM application-layer encryption of the
secret body before it reaches the keyring daemon.

Crypto:
- AES-256-GCM AEAD with per-item 12-byte nonce and 16-byte tag
- Per-item random 16-byte salt (public, in envelope)
- DEK = HKDF-SHA256(ikm=pepper, salt=per-item, info=tag||totp||epoch||itemId)
- Pluggable KeyMaterialProvider SPI with ThreatCoverage self-assessment
- TOTP factor optional (NO_TOTP / STORED_STEP / LIVE_CODE); STORED_STEP
  is explicitly labeled theater in HardenedStatus
- HybridKem scaffold for future X25519 + ML-KEM-768; runtime-probes
  javax.crypto.KEM for "ML-KEM-768" and degrades cleanly on JDK 21

Safety posture (plan decisions):
- Opt-in: stock SecretService/Collection/SimpleCollection APIs unchanged
- Fail-closed: EnvVarKeyMaterialProvider throws if SECRET_SERVICE_PEPPER
  is missing and emits a SECURITY WARNING on construction
- Builder refuses weak providers unless .acknowledgeSecurityTheater(true)
- Non-destructive: never reads/deletes non-hardened items (critical for
  the default collection); hardened.* attributes are reserved
- Callback-only plaintext: no getSecret(String) on the hardened surface

Providers shipped:
- EnvVarKeyMaterialProvider (CI/dev, theater vs same-UID)
- FileKeyMaterialProvider (0600 + optional different-owner check)
- InteractiveKeyMaterialProvider (console prompt at startup)
- NoTotpKeyMaterialProvider (decorator forcing NO_TOTP mode)

Tests (35 new, all passing on JDK 21):
- Envelope format round-trip + malformed/truncated/magic-miss rejections
- TOTP step determinism and seed/step sensitivity
- HybridKem X25519 encap/decap round-trip, PQ probe honesty
- HardenedCollection: round-trip, wrong-pepper fail-closed, non-destructive
  refusal on plaintext items, reserved-namespace rejection, rotateEpoch
  rewraps existing items, TOTP STORED_STEP seed round-trip, status reports
- EnvVarKeyMaterialProvider: fail-closed, mode selection, threat coverage

Deviations from the plan (documented, for follow-up PRs):
- Added hardened package inside the existing module instead of a reactor/
  multi-module split to avoid disrupting the 2.0.1-alpha artifact layout
  and CI paths
- HybridKem encap/decap is X25519-only in v1; ML-KEM path is gated behind
  a runtime probe and stubbed returning zero-length shared secret so the
  classical path always wins until JDK 24 is the build floor
- v1 DEK derivation uses pepper directly (no persisted epoch keypair);
  rotateEpoch() rewraps under a new epoch id but does not yet destroy
  an epoch private key, so it does not provide cryptographic forward
  secrecy. Planned for v2 once multi-module split lands

See docs/vision.md and the design plan for the honest threat model --
in particular, this layer is security theater against a same-UID attacker
unless the pepper is hardware-backed (TPM/PKCS#11/YubiKey).

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
Reverses the in-flight module split and drops the JDK 21+ javax.crypto.KEM
path per updated direction:

- pom.xml: version 3.0.0-alpha, single-module, JDK 17 floor unchanged.
  Adds bcprov-jdk18on 1.78.1 at <scope>provided</scope> <optional>true</optional>
  so consumers who do not use the hardened PQ path pay no transitive cost.
- module-info.java: keeps hardened.* exports in the one existing JPMS module;
  adds `requires static org.bouncycastle.provider` for PQ opt-in via
  reflection.
- HybridKem.java: replaces the `javax.crypto.KEM` / ML-KEM-768 reflection
  probe with a BouncyCastle class probe
  (`org.bouncycastle.pqc.crypto.mlkem.MLKEMKeyPairGenerator`). The encap/decap
  stubs still return a zero-length PQ shared secret; the follow-up wiring to
  MLKEMKEMGenerator / MLKEMKEMExtractor does not require a JDK bump.

Note on the "JDK 9 binary" ask: the project's declared minimum remains JDK 17
(records, switch expressions, JPMS exports used in both core and hardened).
Lowering below 17 would require removing Java records from ThreatCoverage /
HardenedStatus / HybridKem.Encapsulation / FakeCollection.Item and the switch
expression in HardenedStatus#timeBindingLabel -- flag if a true JDK 9 class-file
target is desired and this can be done as a mechanical follow-up.

Tests: 35/35 hardened tests pass on JDK 17 with BouncyCastle absent (classical
fallback) and with BouncyCastle present (probe succeeds, PQ flag available).

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
Splits the build into a Maven reactor and migrates the post-quantum path to
the standard javax.crypto.KEM API (JEP 452, JDK 21+).

Module layout
- pom.xml is now the reactor (packaging=pom, version=3.0.0-alpha) with
  shared dependencyManagement and pluginManagement.
- core/ -- artifactId=secret-service, JDK 17. Existing classical Secret
  Service 0.2 client; coordinates unchanged so existing 2.x consumers can
  bump versions without changing groupId/artifactId.
- hardened/ -- artifactId=secret-service-hardened, JDK 21. Opt-in app-layer
  encryption wrapper. Depends on core; declares bcprov-jdk18on:1.78.1 as
  <scope>provided><optional>true. Hardened module-info uses
  `requires static org.bouncycastle.provider`.

HybridKem refactor (Option B)
- Removed the BouncyCastle-class probe; now probes the standard
  `javax.crypto.KEM.getInstance("ML-KEM-768")` SPI.
- Added new PqProviderBootstrap helper: idempotent, reflective, registers
  BouncyCastleProvider via Security.addProvider only when the caller has
  opted into PQ via HybridKem(true). Default constructions never load BC.
- Real ML-KEM encap/decap wired via javax.crypto.KEM.Encapsulator /
  Decapsulator (no more zero-length stubs).
- kemCiphertext format extended to `uint16_be(x25519_spki_len) || x25519_spki
  || pq_ciphertext` so hybrid envelopes round-trip; classical envelopes set
  pq_ciphertext to empty.
- Added generatePqKeyPair() for the PQ half of an epoch keypair and a
  two-private-key decapsulate(...) overload; classical-only overloads kept
  for backwards compatibility.

Honest documentation of the BC 1.78 KEM SPI gap
- Concrete probe shows BouncyCastle 1.78.1 ships ML-KEM/Kyber under
  BouncyCastlePQCProvider via legacy KeyPairGenerator names ("KYBER768" etc.)
  but does NOT register it through the javax.crypto.KEM SPI. Only DHKEM
  (SunJCE), SNTRUPRIME, and NTRU (BCPQC) are KEM-SPI services today.
- Therefore on JDK 21-23 the wrapper degrades to X25519-only even with BC
  on the classpath. Real PQ via the standard API arrives on JDK 24+ (where
  SunJCE provides ML-KEM) or with a future BC release that wires the SPI.
- PqProviderBootstrap log message says exactly this; PqProviderBootstrapTest
  asserts ensurePqProvider() reports the truth and pins the BC-1.78 gap so
  it will start failing (in a good way) when BC ships KEM-SPI support.

Tests (41 total, 40 pass + 1 PQ-conditional skip on JDK 21 + BC 1.78.1)
- HybridKemTest: classical round-trip, hybrid round-trip (assumeTrue PQ),
  envelope-flag rejection, packed kemCiphertext shape.
- PqProviderBootstrapTest: idempotency, honesty, BC-1.78 gap pinning.
- All pre-existing 35 hardened tests continue to pass after the move.

CI
- .github/dbus-mock/Dockerfile: bumped FROM eclipse-temurin:17 to :21 so the
  hardened module compiles. Core still targets release=17 under JDK 21.

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
This lands the three-item recommended posture: BC 1.82 so javax.crypto.KEM
really produces ML-KEM-768, ML-KEM-768 as the default PQ component, and a
kem_id byte in the envelope header for future HQC / triple-hybrid swaps.

BouncyCastle 1.82
- pom.xml: bouncycastle.version 1.78.1 -> 1.82.
- BC 1.82 registers the KEM SPI under the generic name "ML-KEM" (parameter-
  driven via org.bouncycastle.jcajce.spec.MLKEMParameterSpec), while the JDK
  24+ stock SunJCE registers the explicit "ML-KEM-768". PqProviderBootstrap
  now probes both names, caches the resolver, and exposes
  mlKem768Algorithm() so HybridKem uses whichever the provider supplies.
- HybridKem#generatePqKeyPair picks the matching KeyPairGenerator path:
  stock JDK uses KeyPairGenerator.getInstance("ML-KEM-768") with no spec;
  BC 1.82 uses KeyPairGenerator.getInstance("ML-KEM") + reflective
  MLKEMParameterSpec.ml_kem_768. All BC references stay reflective so the
  module still compiles without bcprov-jdk18on on the classpath.
- PqProviderBootstrap log message replaces the BC-1.78 gap hand-wringing
  with an actionable message ("add bcprov-jdk18on 1.82+ or move to JDK 24").

Envelope algorithm agility
- New kem_id(1) byte inserted after flags in the v1 envelope header:
    magic(4) | version(1) | flags(1) | kem_id(1) | salt_len(1) | ...
- Constants: KEM_ID_NONE=0x00, KEM_ID_X25519_MLKEM768=0x01,
  KEM_ID_X25519_HQC192=0x02 (reserved), KEM_ID_X25519_MLKEM768_HQC192=0x03
  (reserved). Readers reject unknown ids via an explicit label fallthrough;
  future HQC/triple-hybrid land without a format migration.
- HybridKem.kemId() returns the id matching the current config; honest
  algorithmLabel() now derives from the id via Envelope.kemIdLabel().
- HardenedCollection stamps kem_id into every envelope and attaches a
  "hardened.kem.id" public attribute (0x00..0xff string) alongside the
  existing "hardened.kem" string label.
- FLAG_PQ_HYBRID kept as a redundant fast-path bit; kem_id is authoritative.

Tests (43 total, 43 pass, 0 skipped)
- EnvelopeTest: new kemIdRoundTripsAllReservedValues, kemIdLabelIsSensible,
  updated constructor call sites, updated salt_len offset in the malformed
  test (now at byte 7 instead of 6).
- HybridKemTest: existing hybrid round-trip no longer skips -- BC 1.82 is
  on the classpath, PqProviderBootstrap succeeds, hybrid kemCiphertext
  encap/decap round-trip passes.
- PqProviderBootstrapTest: replaces the BC-1.78-gap pin with a positive
  assertion that BC 1.82 actually wires ML-KEM through the KEM SPI, and
  keeps an honesty test that reported availability matches reality.

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
Triple hybrid (X25519 + ML-KEM-768 + HQC-192) is not a default we plan to
ship, so removing the pre-allocated constant avoids implying forward
commitment. Algorithm agility via the envelope kem_id byte is unchanged:

- Removed Envelope.KEM_ID_X25519_MLKEM768_HQC192 and its kemIdLabel case.
- Updated class-level Javadoc: KEM_ID_X25519_MLKEM768 is the recommended
  default; KEM_ID_X25519_HQC192 stays reserved for the NIST Round 4 HQC
  alternative; exotic combinations are explicitly NOT shipped and NOT
  recommended. Consumers that genuinely need one can allocate a new id
  value without a format migration -- unknown ids round-trip through the
  generic "kem-id-0xNN" label fallthrough.
- EnvelopeTest.kemIdRoundTripsAllReservedValues now exercises an
  arbitrary 0x7f id in place of the dropped triple-hybrid constant so the
  agility path remains covered. kemIdLabelIsSensible keeps the fallthrough
  assertion.

43/43 hardened tests still pass.

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
…lder invariants

Addresses the critical + high + medium items from the security/usability audit.

Critical
- HardenedCollection#rotateEpoch: create-then-delete instead of
  delete-then-create. The new envelope is durably written BEFORE the old
  one is removed, so a crash between the two calls never loses data. If
  createItem fails we log and keep the old envelope intact; the new test
  rotateEpochSurvivesCreateFailure pins the invariant by injecting a
  createItem failure via FakeCollection.setNextCreateItemFails.

- HardenedCollection#withSecrets: fail-fast on any per-item decryption
  failure (tampered envelope, wrong pepper, wrong TOTP, stale epoch, ...).
  The callback is no longer invoked with a silently-truncated map. Also
  scoped the iteration to items carrying hardened.version=1 so foreign
  items in shared collections (e.g. default) are invisible to the batch
  API, matching the non-destructive read policy of withSecret.
  New tests: withSecretsFailsFastOnAnyItemFailure,
  withSecretsScopesToHardenedItemsOnly.

High
- HardenedCollection.builder(CollectionInterface, KeyMaterialProvider):
  provider is now a required factory argument instead of a .keyMaterial(p)
  setter that could be omitted. Both args are null-checked at the builder
  boundary. Eliminates the late NPE and the whole "I forgot to set the
  provider" misuse class. New test builderRequiresKeyMaterialAtCompileTime.

- HardenedCollectionInterface#withSecret / #withSecrets: added explicit
  @APinote warning that returning the plaintext, new String(chars), the
  char[] itself, or any derivative defeats the zeroing guarantee. Keeps
  the callback's generic R (power) but points users at the safe pattern
  (verdict / boolean / derived hash). withSecrets gets its fail-fast
  contract documented alongside.

Medium
- HardenedCollection#withSecret: unified the three distinct WARN messages
  for base64 decode failure / missing magic / parse failure into a single
  "envelope rejected for {path}" WARN. Distinguishing details moved to
  DEBUG. Removes a weak log-side-channel between AEAD tag mismatch and
  envelope format errors.

- EnvVarKeyMaterialProvider: store pepper as a char[] field instead of
  bouncing through a live String, and clone-on-get. Javadoc now says
  plainly that the original env-var String lives outside this instance
  and cannot be scrubbed; this is why threat_coverage.sameUid=NONE.
  (Reverted an intermediate byte[]-backed design that would have
  mis-encoded non-ASCII peppers through UTF-8 re-encoding.)

- PqProviderBootstrap#ensurePqProvider: now synchronized so two threads
  cannot both race into Security.addProvider or probe twice.
  AtomicReferences kept for the cached result.

Observation
- HybridKem's builder flag enablePostQuantum(boolean) now has an @APinote
  stating that v1 only stamps the envelope kem_id; the DEK derivation
  does not yet consume the KEM shared secret. Real forward-secrecy via
  persisted epoch keypairs is v2.

Tests: 47/47 pass (up from 43). New tests cover rotation atomicity
under failure, withSecrets fail-fast semantics, withSecrets scoping,
and builder argument validation.

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
…ompare

The most common use of withSecret is "does this match what the user typed?"
-- a predicate-shaped use case that tempts every developer to write
Arrays.equals(plain, typed) inside the callback. That is a textbook password-
compare timing oracle: Arrays.equals short-circuits on the first differing
char and leaks the position of the mismatch through latency.

matchesSecret(path, candidate) closes that path:

- The plaintext is never handed to caller code. Decryption happens inside
  the library; the caller gets back only Optional<Boolean>.
- Comparison is constant-time across the min(len(plain), len(candidate))
  bound, accumulating per-char XOR into a diff register.
- The candidate char[] is zeroed before return (match, mismatch, or empty).
- Non-hardened, missing, or tampered items yield Optional.empty() without
  running the compare at all.

Reuses the existing withSecret decryption path (so envelope parsing, AEAD
tag validation, and TOTP handling are shared; single source of truth for
the "is this item readable" policy).

HardenedCollectionInterface Javadoc now explicitly directs "candidate
comparison" callers to matchesSecret and "derive a non-sensitive value"
callers to withSecret, with the existing plaintext-escape @APinote on the
latter.

Tests (54 total, was 47): six new coverage points --
- matchesSecretReturnsTrueForEquality + candidate-zeroed assertion
- matchesSecretReturnsFalseForMismatch + candidate-zeroed assertion
- matchesSecretReturnsFalseForLengthMismatch
- matchesSecretEmptyForNonHardenedItem + candidate-zeroed assertion
- matchesSecretEmptyForMissingItem + candidate-zeroed assertion
- matchesSecretEmptyForTamperedEnvelope + candidate-zeroed assertion
- constantTimeEqualsIsLengthIndependentOfFirstMismatchIndex (pins the
  XOR-accumulating comparison at front, middle, end, and null inputs)

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
New Maven module secret-service-hardened-tpm2 ships the TPM2-backed
KeyMaterialProvider we have been pointing to as the "real" security default
throughout the plan. It is the first provider whose ThreatCoverage actually
reports sameUid=REAL (the HardenedCollection.Builder accepts it without
acknowledgeSecurityTheater).

Why a separate Maven module

Microsoft TSS.Java 1.0.0 has a class in the unnamed package (TSSMain.class
in the JAR root), so it cannot be loaded as a JPMS module -- jdeps refuses
to derive a module descriptor and the "hardened" named module cannot
`requires` it. Rather than dropping JPMS for "hardened" or burying every
TSS.Java call behind reflection (bad ergonomics for the dozen struct types
involved), the TPM integration lives in its own classpath-only sibling
module with direct imports. Consumers who want TPM support add both
secret-service-hardened-tpm2 and TSS.Java to their runtime classpath;
consumers who don't pay nothing (TSS.Java is provided/optional).

Module layout
- hardened-tpm2/ -- new, artifactId=secret-service-hardened-tpm2, JDK 21.
  No module-info.java; depends on secret-service-hardened and
  com.microsoft.azure:TSS.Java:1.0.0 (provided/optional).
- parent pom: registered as a third reactor module; dependencyManagement
  pins tss.java.version=1.0.0.

Implementation
- Tpm2SealedBlob: on-disk container (magic="TPM2BLOB", version=1, policyKind
  byte, uint16-length-prefixed public + private parts). Atomic write via
  temp file + ATOMIC_MOVE at mode 0600; readFrom() fails closed if the file
  is group/other readable.
- Tpm2KeyMaterialProvider: AutoCloseable. Constructor opens the TPM
  (platform device or simulator via Supplier<Tpm>), creates a transient
  RSA-2048 storage-root primary under TPM_RH_OWNER, TPM2_Load-s the sealed
  keyed-hash blob, attaches the password as AuthValue, TPM2_Unseal-s, caches
  the bytes as char[], FlushContext-s both handles, closes the TPM. getPepper()
  clones the cache; close() zeroes it. threatCoverage() reports
  sameUid=REAL/crossUid=REAL/offline=REAL.
- Tpm2Provisioner: one-shot CLI. SecureRandom 32-byte pepper, seals under a
  password policy via TPM2_Create (TPMA_OBJECT userWithAuth + noDA + fixedTPM
  + fixedParent), serialises via TPM2B_PUBLIC.toTpm()/TPM2B_PRIVATE.toTpm(),
  writes the blob with Tpm2SealedBlob.writeTo. Zeroes pepper and password on
  return. Flags: --out <path> --password <pw> [--simulator].

Tests (8 active + 2 simulator-gated, total 10)
- Tpm2SealedBlobTest: binary round-trip, bad-magic rejection, unknown
  policy-kind rejection, empty-parts rejection, file round-trip + mode 0600
  assertion, over-permissive-file rejection.
- Tpm2KeyMaterialProviderTest: class-surface sanity check; over-permissive
  blob file rejection (runs without a TPM); sealUnsealRoundTripViaSimulator
  and wrongPasswordFailsToUnseal gated on probing localhost:2321 (the
  Microsoft / IBM TPM simulator port). Tests use Assumptions#assumeTrue so
  they skip cleanly when no simulator is running.

Non-goals for this pass
- PCR-bound policy. PolicyKind.PCR is declared in the blob enum but not yet
  wired; shipping only PASSWORD policy keeps v1 focused.
- Persistent primary-key handle. Each provider construction rederives the
  owner-hierarchy primary. A later pass can add a persistent handle to cut
  ~50 ms startup latency if it turns out to matter.
- Wiring Tpm2KeyMaterialProvider into HardenedCollection's default builder
  path. That belongs in application-level docs, not the library.

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
Two honesty fixes to the TPM provider's security posture, motivated by the
simple fact that a TPM authenticates possession of secrets and platform
state, NOT the identity of the caller. Same-UID impersonation is possible
at the TPM level: any process that can open /dev/tpmrm0 can issue TPM 2.0
commands, and a same-UID attacker can also ptrace this JVM and read the
already-unsealed pepper out of the heap regardless of the policy.

1) threatCoverage.sameUid: REAL -> PARTIAL

The old rationale ("attacker needs the TPM and the password") was
misleading -- a same-UID attacker *has* the TPM (it's a shared host
resource) and can extract the password or the unsealed pepper from the
running JVM's environment / memory. Real same-UID defense requires an
external MAC policy (SELinux label, AppArmor profile, systemd DeviceAllow
/ PrivateDevices) restricting /dev/tpmrm0 access to the legitimate binary.
That is an OS-level control, not a TPM capability.

New rationale spells this out in the ThreatCoverage string so callers who
assert on it in tests see the prerequisite. Offline-theft and cross-UID
ratings stay at REAL (both are genuine TPM defenses).

2) Drop TPMA_OBJECT.noDA from the seal template

The old template disabled Dictionary-Attack lockout on the sealed object.
That was the wrong default for an application secret: a same-UID attacker
who grabs the blob file but not the password could brute-force it forever.
Without noDA, the TPM enforces DA lockout (typically 32 failed attempts ->
standby) for this object, giving genuine rate-limiting. The legitimate
flow supplies the correct password and never trips the counter, so cost
to the good path is zero.

Template flags now: fixedTPM | fixedParent | userWithAuth. The sealed
object is non-migratable, bound to its parent, and authed via the user
AuthValue (password) under the TPM's DA lockout policy.

Test update: the earlier sameUid=REAL assertion is gone; the simulator
round-trip test no longer asserts a specific level (that belongs in the
ThreatCoverage contract tests, not the provider integration test). 10
tests (8 active + 2 simulator-gated skips).

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
…aner errors

Addresses every item from the TPM implementation review (Critical, High,
Medium, Low). 12 tests in hardened-tpm2 (10 active + 2 simulator-gated
skips), up from 10. Hardened module regressions: 54/54 pass.

Critical
- Tpm2Provisioner: remove the --password <plaintext> CLI flag. The argv
  was visible in /proc/<pid>/cmdline, ps(1), shell history, auditd, sudo
  logs -- there is no safe way to take a password on the command line.
  New password sources: --password-stdin (read one line), --password-env
  VAR (read from named env var), --password-fd N (read from /dev/fd/N),
  --password-prompt (java.io.Console.readPassword with echoing disabled,
  default on a tty). At most one may be specified; missing source falls
  back to interactive prompt. Usage message lists all four.
- Tpm2KeyMaterialProvider Javadoc: updated the class header's pepper-
  location claim ("at rest on disk" not "anywhere") and the sameUid=REAL
  quote. Added a "Threat coverage (honest)" section that names PARTIAL,
  states why (TPM authenticates secret + PCR, not caller identity), and
  points at the MAC prerequisite for real same-UID defense.
- Tpm2KeyMaterialProviderTest.sealUnsealRoundTripViaSimulator: the stale
  assertEquals(Level.REAL, sameUid) assertion flipped to PARTIAL, with an
  extra assertion that the rationale string mentions "MAC policy".

High
- Tpm2SealedBlob.PolicyKind: explicit wire bytes via new .wire() accessor
  and fromWire(byte) lookup. Serialisation no longer depends on enum
  ordinal(), so reordering or inserting values cannot change the on-disk
  format. PASSWORD=0x01, PCR=0x02.
- Tpm2SealedBlob.writeTo: temp file is now created mode-0600 from the
  outset via PosixFilePermissions.asFileAttribute, closing the brief
  umask window where Files.write created a world-readable file before
  setPermissions fixed it. Non-POSIX filesystems fall through to the
  create-only path.

Medium
- Tpm2KeyMaterialProvider constructor: wraps TSS.Java's unchecked
  RuntimeException in IOException so callers can rely on the declared
  throws signature. Tpm2SealedBlob.readFrom likewise wraps the
  IllegalArgumentException from fromBytes in IOException for malformed
  files. The tests now catch IOException directly instead of the
  broader RuntimeException.
- Tpm2KeyMaterialProvider.unseal / Tpm2Provisioner.seal: Tpm instance is
  acquired inside the try block with a null-check in finally, so a
  buggy Supplier<Tpm> that throws after partial initialisation cannot
  leak the TPM connection.
- FlushContext failure logs promoted from DEBUG to WARN: these represent
  real resource leaks (transient TPM handles, bounded per chip) that an
  operator should see in production logs. tpm.close() failures stay at
  DEBUG (closing an already-broken connection is expected to be noisy).
- storageRootTemplate: dropped TPMA_OBJECT.noDA. The primary is
  transient and never authenticates so the attribute was a no-op, but
  the posture now matches the leaf seal template exactly. Javadoc
  explains the deliberate deviation from the stock tpm2-tools template.
- New class Tpm2Availability with isAvailable() that probes the
  classpath for tss.TpmFactory WITHOUT loading any TSS.Java-importing
  class (uses Class.forName(name, false, cl)). Two new tests cover it.
  Lets callers degrade gracefully to EnvVarKeyMaterialProvider when
  TSS.Java is absent from runtime.

Low
- Tpm2Provisioner.main: wraps the whole pipeline in try/catch for
  IOException and RuntimeException separately, writing a user-friendly
  error line and exiting with a specific nonzero code (2=args, 3=io,
  4=tpm). No more raw stack traces for tty users.
- Args.parse: unknown flags now throw IllegalArgumentException with
  the offending flag name, which main() prints before the usage line.
- Paths.get -> Path.of.
- Tpm2KeyMaterialProviderTest: removed the private unused() helper
  that existed only to retain a `tss.Tpm` import; the simulator tests
  reference TpmFactory directly and no longer need the dance.

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
…model & guidance

A 1025-line guide that collects, in one place, every threat the library
acknowledges and every Linux-side mechanism a deployer can apply against
it. Inherits the existing project vocabulary verbatim (attacker classes
A/B/C/D, ThreatCoverage Levels NONE/PARTIAL/REAL/NOT_APPLICABLE) so the
doc stays consistent with the runtime ThreatCoverage rationale strings
that providers publish.

Structure (threats first, then defenses, then deployment recipes):

1. Introduction & scope -- audience and explicit non-audience
2. Threat catalogue -- classes A/B/C/D each expanded into concrete
   attack vectors (ptrace, /proc/<pid>/environ, JVM attach, /dev/tpmrm0
   direct open, daemon access, leaked DBUS_SESSION_BUS_ADDRESS, swap
   leakage, hibernation, core dumps, FS snapshots, supply chain, evil
   maid, DMA, cold boot, kernel exploit) plus cross-cutting threats
3. Defense mechanism inventory -- 13 mechanisms each with what/coverage/
   how/limitations: LUKS, POSIX DAC, capabilities, SELinux, AppArmor,
   seccomp-bpf, namespaces, cgroups v2 device controller, memory
   hygiene (mlockall + DisableAttachMechanism + RLIMIT_CORE), systemd
   unit hardening (full directive table), Secure Boot / Measured Boot
   / IMA-EVM, udev rules, xdg-desktop-portal Secret
4. D-Bus policy in detail -- the section the user explicitly asked for:
   system vs session bus distinction, policy XML, what is and isn't
   policy-controllable, polkit interplay, KeePassXC per-item ACL,
   dbus-broker, worked class-B sidecar example
5. Secret Service backend choice -- gnome-keyring vs KeePassXC table
   plus the stacking story (hardened wrapper on top of either backend)
6. LUKS / full-disk encryption -- coverage matrix, TPM-bound LUKS vs
   TPM-sealed application secret distinction, swap/hibernation prereq,
   FS snapshot hazard
7. Distribution-format matrix -- plain binary, .deb/.rpm, OCI, AppImage,
   Flatpak, Snap, Nix; each with TPM access story, D-Bus access story,
   class-delta vs unpackaged JAR; final summary table by class
8. Recommendation matrix -- six deployment scenarios mapped to backend,
   format, MAC, D-Bus posture, LUKS, hardened provider, with rationale
9. Concrete sample configurations -- 9 drop-in snippets each with its
   verification command: systemd unit, AppArmor profile, SELinux
   module, D-Bus session-bus policy XML, Dockerfile + compose,
   Flatpak manifest, snapcraft.yaml, udev rule, JVM launcher
10. Honest anti-checklist -- 10 things nothing in the doc defends
    against (in-JVM malicious code, live RAM, firmware, kernel,
    user disable, callback misbehaviour, child processes, TOC/TOU,
    sibling services, the threat-model document itself)
11. References -- project docs, specifications, man pages,
    distribution-format docs, external hardening guides

Pointer updates so the doc is discoverable:
- README.md Security Issues section: one-line pointer at the top
- docs/vision.md: one-line pointer near the top

No code changes, no Maven changes, no test changes -- pure documentation.

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
Three sections rewritten end to end (the foundational sections 1-6 and
the anti-checklist + references at 10-11 are unchanged):

- §7 Desktop App consumer scenarios. Step-by-step walkthrough by
  distribution format, from a downstream library consumer's perspective:
  plain tarball, self-contained JAR, jpackage .deb/.rpm, AppImage,
  Flatpak, Snap, Nix. Each subsection covers: what your users get out of
  the box, what you must add to the package/launcher, recommended
  KeyMaterialProvider, threat-class coverage table, concrete pitfalls,
  and a verifiable ship-readiness check. Closes with a short decision
  tree. The cross-platform "this library is Linux-only" caveat is stated
  in §1 once instead of repeated; the recommendation is now framed as
  "jpackage .deb/.rpm preferred for hardened desktop apps; Snap if you
  want the strict sandbox + TPM combination; avoid AppImage and Flatpak
  for secret-handling unless you accept their constraints (portal-only,
  no TPM)."

- §8 CI Tool consumer scenarios. Distinct because CI is headless, often
  ephemeral, and rarely has a Secret Service daemon at all. Walks
  release JAR, .deb/.rpm self-hosted runner, OCI container (the dominant
  CI format), GitHub Actions / GitLab CI integration, self-hosted
  controller talking to KMS/Vault, and Snap classic (rare ops-tool
  case). Calls out three things explicitly: (1) inside containers
  Secret Service is unavailable so use env-var or file-based providers
  fed by the orchestrator's secret store; (2) TPM access via
  --device=/dev/tpmrm0 is forbidden in most managed CI; (3) build logs
  are the most underestimated class-C path. Closes with a CI decision
  tree.

- §9 Mitigation matrices and sample configurations. New §9.1
  format-vs-class summary, §9.2 backend stacking summary, and
  centerpiece §9.3 mitigation-vs-environment matrix:
    * 7 environment columns (single-user desktop, multi-user host, OCI
      container, Flatpak sandbox, Snap strict, headless server,
      ephemeral CI job)
    * ~30 mitigation rows grouped by category (storage, DAC, MAC,
      capabilities, sandboxing primitives, JVM memory hygiene, D-Bus
      posture, TPM hardware root, application-layer)
    * Cell verdicts: ✓ available+recommended / ◐ partial (footnoted)
      / — not applicable / ✗ unavailable
    * Footnotes for the non-obvious partial cells (CAP_IPC_LOCK on
      systemd-user, MAC under Snap tpm interface, OCI TPM device
      pin-through, KeePassXC interactivity in headless contexts,
      EnvVar provider's loud warning).
  The existing nine sample configurations (systemd unit, AppArmor,
  SELinux, D-Bus XML, Dockerfile + compose, Flatpak manifest,
  snapcraft.yaml, udev rule, JVM launcher) are preserved verbatim and
  renumbered to §9.4-§9.12; §3 / §7 / §8 cross-references to them
  updated accordingly. TOC at the top of the doc updated to match.

Result: a downstream consumer (desktop app developer or CI tool
developer) can pick their lane, find their distribution format, and
read off concrete actions; or use §9.3 as a single-page lookup grid for
"which guards apply in my environment."

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
The previous §7.4 listed individual missing pieces (AppArmor, D-Bus
policy, systemd unit, udev rule) without naming the structural cause.
The rewrite leads with the single one-liner that explains all of them at
once, then enumerates the consequences:

  AppImage's defining feature -- no install path -- is exactly what
  every Linux security framework needs to attach a policy. For an app
  that handles secrets, that's the whole problem.

Other changes in §7.4:

- Added an asymmetry note vs Flatpak / Snap, since users routinely lump
  the three "single-file portable" formats together. AppImage's lack of
  a stable path is also why it can talk to the TPM directly (no sandbox
  to break) -- but that's the same reason it has no other defenses
  either. Flatpak and Snap trade flexibility for a sandbox; AppImage
  makes neither half of the tradeoff.
- Added a "when AppImage is fine" line so the recommendation is not
  read as a blanket condemnation -- the format is genuinely good for
  one-off tools, portable USB-stick apps, demos, and bridging old
  distros via bundled glibc.

No other sections touched.

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
…d for core)

The reactor already produces three independent jars (secret-service,
secret-service-hardened, secret-service-hardened-tpm2) at the same parent
version, so the layout was already correct. Two gaps closed:

1. Parent dependencyManagement now lists all three artifacts. Previously
   only `secret-service` was BOM-pinned; consumers importing the parent
   POM with <scope>import</scope> got no version pin for the optional
   hardened / hardened-tpm2 modules. With this change, one BOM import
   pins all three at the same version (verified end-to-end with a
   synthetic consumer pom -- mvn dependency:tree shows all three at
   3.0.0-alpha with no <version> elements on the dependencies).

2. README §Usage rewritten to a "Coordinates" table that documents the
   three-artifact split, with separate <dependency> snippets for each
   row, the BOM-import pattern for pinning all three at once, and the
   legacy 2.x.x snippet preserved for backward-compat consumers.

Zero-overhead invariant verified for standard consumers:

    $ mvn dependency:tree    # synthetic pom depending only on secret-service
    \- de.swiesend:secret-service:jar:3.0.0-alpha:compile
       +- at.favre.lib:hkdf:jar:2.0.0:compile
       +- com.github.hypfvieh:dbus-java-core:jar:4.3.1:compile
       +- com.github.hypfvieh:dbus-java-transport-native-unixsocket:jar:4.3.1:compile
       \- org.slf4j:slf4j-api:jar:2.0.17:compile

No hardened code, no BouncyCastle, no TSS.Java reachable from a
secret-service-only consumer. The hardened module's BC dep is
provided/optional; the hardened-tpm2 module's TSS.Java dep is also
provided/optional, so neither leaks transitively even when the consumer
opts into those layers.

Reactor builds clean; no other changes.

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
…_and_mitigation.md

Shorter file name; the "mitigate_" prefix was redundant given the doc's
own §3 + §9 already cover mitigations explicitly. Updates the two
existing pointers (README.md security-issues blurb + the coordinates
section, docs/vision.md header) to the new path. No content changes.
…store + forward secrecy

The previous "kem_id byte without KEM consumption" was a phantom commitment --
items advertised PQ but the ML-KEM shared secret never reached HKDF.  This
change wires the KEM end-to-end so PQ-flagged envelopes are genuinely PQ-
protected and rotateEpoch() yields real forward secrecy by destroying the
previous epoch's private keys.

Wire format change (Envelope v1.1)
- New length-prefixed kem_ct field between epoch_id and nonce:
    magic(4) | version(1) | flags(1) | kem_id(1) |
    salt_len(1) | salt(16) |
    epoch_len(1) | epoch_id |
    kem_ct_len(2) | kem_ct[m]   <- new
    nonce(12)    | aead_ct
- Constructor invariant: (kem_id == KEM_ID_NONE) <=> (kem_ct.length == 0).
- Format version stays at 1; this is alpha, no released envelopes to migrate.

EpochKeystore (new class)
- Persists {epoch_id -> (X25519 keypair, ML-KEM keypair)} as an encrypted
  hardened item inside the wrapped collection. Label is reserved
  (__hardened_epoch_keystore__) and tagged with hardened.kind=epoch-keystore.
- Encrypted under a deterministic AES-256-GCM KEK derived via HKDF from the
  pepper (fixed salt + info), so the same provider can re-open it across
  JVM restarts.
- getOrCreate(epochId) lazily generates and persists keypairs the first time
  an epoch sees a write; removeEpoch(epochId) is the forward-secrecy primitive.
- The keystore item is filtered out of withSecrets / rotateEpoch's iteration
  so it is invisible to callers and never mistakenly rewrapped.

HardenedCollection wiring
- createItem: when enablePostQuantum(true), encapsulateForWrite() looks up
  (or creates) the current epoch's keypairs and runs HybridKem.encapsulate
  to produce (sharedSecret, kemCiphertext). The ciphertext lands in the
  envelope's new kem_ct field; the sharedSecret is mixed into HKDF.
- withSecret: decapsulateForRead() consults the keystore, decapsulates the
  envelope's kem_ct, and feeds the resulting sharedSecret to HKDF on read.
  Missing-epoch in keystore (rotated and destroyed) raises IllegalState
  which decryptToChars converts to a logged Optional.empty() -- a clean
  failure path.
- deriveDek now takes a kemSecret byte[] (empty for non-PQ items). The
  HKDF info string length-prefixes the kem secret separately from the
  rest, so PQ and non-PQ items derive disjoint keys (domain separation).
- rotateEpoch: after every old-epoch item is rewrapped under the new epoch
  keypair, the old entry is removed from the keystore. Pre-rotation
  envelopes captured by an attacker can no longer be decapsulated -- this
  is the class-D / HNDL defense the kem_id byte advertises.

HybridKem: added importX25519KeyPairFromPkcs8 and importMlKemKeyPair so
EpochKeystore can rehydrate persisted keypairs across JVM restarts.

Tests (59 hardened tests, +2 net)
- EnvelopeTest:
  * roundTripPreservesAllFields_withKemCt: 1090-byte kem_ct round-trip
  * roundTripPreservesAllFields_classicalNoKemCt: classical path
  * kemIdAndKemCtMustBeConsistent: invariant pinned (NONE iff empty)
  * kemIdRoundTripsAllReservedValues: PQ ids carry kem_ct, NONE doesn't
- HardenedCollectionTest:
  * defaultBuilderWritesKemIdNone: opt-in remains opt-in
  * postQuantumRoundTripWritesKemCtAndRecoversPlaintext: end-to-end PQ
    write + read; kem_id, kem_ct, FLAG_PQ_HYBRID, plaintext recovery all
    pinned. Verifies kem_ct is >1000 bytes (real ML-KEM-768 + X25519 SPKI).
  * rotateEpochProvidesForwardSecrecyForPqItems: write under PQ, snapshot
    raw envelope bytes, rotateEpoch, replay the captured envelope at a
    fresh path -- read returns Optional.empty() because the previous
    epoch keypair was destroyed.

Reactor green: 4 modules, 71 tests pass (59 hardened + 12 hardened-tpm2
with 2 simulator-gated skips). Compatible with dbus-java 5.2.0.

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
…covery

Two new sections + a refresh of class-D PQ honesty now that the wrapper
actually wires ML-KEM into HKDF (commit 9a75ab3):

§1.1 Do I need this?  (new)
   A five-question walk-through that filters consumers into "core only" vs
   "hardened pays off" vs "hardened-tpm2 pays off" vs "enable PQ on top".
   The honest one-liner: if your deployment is single-user, single-host,
   no off-host sync, you do not need the hardened layer -- core is enough.
   Reduces the cognitive load on casual readers who might otherwise add
   the hardened module on misplaced threat-model assumptions.

§10 Backup, escrow, and recovery  (new, before the anti-checklist)
   Names every artefact the operator manages (pepper, seal password,
   tpm2blob file, EpochKeystore item, TOTP seed, keyring database) and
   gives a per-artefact "what to back up, where, and what loss costs"
   table. Per-provider backup recipes for EnvVar / File / Tpm2 with
   shell snippets. Recovery procedures for: lost seal password, lost
   TPM, lost keyring file, lost EpochKeystore item, forgotten provider.
   Multi-step pepper-rotation procedure (different from rotateEpoch).
   Backup discipline checklist + an explicit "when not to back up"
   list that calls out the no-class-C-benefit case (env-var pepper in
   the same backup as the keyring).

§2.4 Class D  (refreshed)
   Now reflects the real PQ wiring: enablePostQuantum(true) feeds
   ML-KEM-768's shared secret into HKDF; rotateEpoch destroys the prior
   epoch keypair via EpochKeystore. The HNDL row of the matrix flips
   from "◐ if archived" to "✓ if archived (forward-secret via
   rotateEpoch)". Local-only deployments still get NOT_APPLICABLE.

§9.3 mitigation × environment matrix  (one row updated)
   Hybrid PQ KEM row labelled with the .enablePostQuantum(true) builder
   call to make the opt-in obvious; archive cell upgraded ◐→✓.

TOC + section numbering updated:
   - new TOC entry "1.1 Do I need this?"
   - new TOC entry "10 Backup, escrow, and recovery"
   - anti-checklist renumbered §10 → §11; references §11 → §12

No code changes in this commit; PQ wiring landed separately.

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
…e examples

Step 1 -- pepper sources
  Today the provisioner generates the pepper internally; lose the TPM and
  every item is unrecoverable. New flags let an operator supply the pepper
  out-of-band so the same pepper can be re-sealed against a different TPM
  on a different host (e.g. after motherboard swap or hardware failure):

    --pepper-stdin       read one line from stdin
    --pepper-env VAR     read from environment variable VAR
    --pepper-fd N        read one line from file descriptor N
    (default)            generate base64-encoded 32-byte SecureRandom

  Three guardrails:
  - --password-stdin and --pepper-stdin are mutually exclusive (only one
    reader can hold fd 0). Same check covers --password-fd 0.
  - Default pepper is now base64-ASCII (was 32 raw random bytes) so the
    UTF-8 round-trip in Tpm2KeyMaterialProvider.utf8ToChars is lossless.
    Raw random bytes had ~50% chance of containing invalid UTF-8 sequences
    and corrupting on read -- the prior in-tree round-trip test only ran
    against the simulator (skipped in CI without it) so the bug was
    latent. Switching to base64 ASCII fixes it for all paths.
  - Operator-supplied pepper is read as UTF-8 text, so any input the
    operator pasted (base64 from openssl, a passphrase, etc.) round-trips
    losslessly through both encoding directions.

  Tests: 8 new in Tpm2ProvisionerArgsTest -- default-pepper round-trip
  invariant, freshness, env source, stdin/stdin conflict, fd-0/stdin
  conflict, missing-arg-value, dual-pepper-source.

Step 2 -- usage examples
  New docs/usage_examples.md (356 lines, 10 worked snippets):
    1. Core read & write
    2. Legacy SimpleCollection
    3. Hardened minimal opt-in
    4. matchesSecret for password verification (with the wrong-way
       Arrays.equals counterexample called out)
    5. withSecrets fail-fast batch read
    6. enablePostQuantum + rotateEpoch
    7. Implementing a custom KeyMaterialProvider (Vault example)
    8. Tpm2Provisioner CLI (default flow, operator-supplied escrow flow,
       headless CI flow, on-disk artefact description)
    9. Runtime use of Tpm2KeyMaterialProvider with a sealed blob
    10. Graceful fallback via Tpm2Availability when TSS.Java is absent

  Pointer added to README §Functional API.

Reactor green: 79 tests pass (59 hardened + 20 hardened-tpm2 with 2
simulator-gated skips); zero regressions.

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
… propagates

A try-with-resources on HardenedCollection alone used to leave the
provider's cached pepper in heap until JVM exit -- a quiet failure of
the no-leak design every other API in the hardened layer enforces.
Fix:

- KeyMaterialProvider extends AutoCloseable with a default no-op close().
  Stateless providers ignore the change; stateful ones (Tpm2KeyMaterialProvider,
  EnvVarKeyMaterialProvider) override.
- HardenedCollection.close() closes the provider first (zeroes the pepper
  cache, releases TPM handles), then the wrapped CollectionInterface.
  Each failure is logged and swallowed independently so a broken provider
  cannot strand the wrapped connection and vice versa.
- New test closePropagatesToProvider asserts the propagation contract via
  a counting test provider.

Tests: 60 hardened tests pass (+1 net new); zero regressions.

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
For consumers who don't have a TPM (or who use EnvVarKeyMaterialProvider
in production after acknowledging the same-UID theatre), the per-item
DEK derivation today is HKDF-SHA256(pepper) -- meaning a class-C attacker
who exfiltrates the keyring + the pepper can brute-force user-typed
passphrases at billions of guesses per second. Argon2id wraps the inner
provider so each guess costs tens of milliseconds + tens of MB.

What this provider does
- Wraps any KeyMaterialProvider; runs Argon2id once at construction time
  (so failures surface immediately) and caches the 32-byte derived key
  base64-encoded as ASCII (round-trip-safe through HardenedCollection's
  utf8 encoding path).
- Three named profiles drawn from RFC 9106 §4: EMBEDDED (16 MB, ~30 ms),
  INTERACTIVE (64 MB, ~150 ms, default), SENSITIVE (256 MB, ~600 ms).
- Promotes the inner provider's class-C ThreatCoverage one notch
  (NONE→PARTIAL or PARTIAL→REAL) and passes the others through verbatim --
  Argon2 only changes the offline-brute-force row, not class A/B/D.
- close() zeroes the cached pepper, scrubs the salt, and propagates to
  the inner provider.

What this provider does NOT do
- Class A (ptrace, JVM heap inspection) is unchanged. Argon2 doesn't
  help against attackers who can read the post-stretch pepper directly.
- Class D / HNDL: orthogonal; PQ KEM is the relevant primitive.
- Caller-supplied salt MUST be at least 8 bytes; deployment-wide fixed
  salts are appropriate (per-pepper not per-item) so the same Argon2
  invocation amortises across the full collection.

Implementation
- Reflective access to BouncyCastle's Argon2BytesGenerator and
  Argon2Parameters.Builder, matching PqProviderBootstrap's pattern. The
  hardened module class-loads without bcprov-jdk18on; consumers who use
  this provider must add it to their runtime classpath. The constructor
  probes for BouncyCastle and fails closed with a clear diagnostic
  pointing at Maven coordinates if missing.

Tests (7 new)
- stretchedPepperIsDeterministicAndAscii: same input + salt + profile
  yields identical output; output is base64 ASCII (44 chars for 32-byte
  derived key); ASCII guarantees lossless round-trip through UTF-8.
- differentSaltsProduceDifferentStretchedPeppers: salt actually
  participates in the derivation.
- threatCoverageBumpsClassCFromNoneToPartial: confirms the only-class-C
  promotion contract; sameUid/crossUid/networkHndl pass through.
- rejectsTooShortSalt + rejectsNullArgs + closeZerosCacheAndClosesInner.

Hardened module: 67 tests pass (+7 net). No regressions.

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
The method had no legitimate operator use case: hard-coding an epoch id
silently disables forward secrecy (rotateEpoch generates a new UUID; if
operator code keeps overriding it, the keystore never rotates), and a
typo in a config file partitions items into parallel unreadable epochs
("yourapp-prod " vs "yourapp-prod" → two epochs, neither readable from
the other side). The only legitimate use was for in-package tests that
needed a deterministic id.

Comment in the source spells out why it's not public so a future
contributor doesn't reflexively expand the visibility.

No external callers in the codebase. 67 hardened tests still pass.

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
A small static helper that exercises a configured HardenedCollection and
returns a structured report. Three things it surfaces that nothing else
in the API does in one place:

1. The provider's ThreatCoverage as structured data (instead of the
   constructor's INFO log line).
2. JVM-flag warnings: missing -XX:+DisableAttachMechanism and
   -XX:-HeapDumpOnOutOfMemoryError. Notes that ulimit -c 0 must be
   verified externally because it isn't visible from the JVM.
3. Canary round-trip: when an item path is supplied, the check decrypts
   it via HardenedCollection.withSecret -- the only thing that proves
   "yes, my TPM-sealed pepper is reachable and the EpochKeystore is
   intact" without running real workloads.

Provides:
- check(coll, canaryItemPath) -> Report
- Report record with healthy() / toReport() (multi-line operator string)
  / structured fields
- toMap(report) for JSON serialisation in /healthz handlers
- Severity enum (OK/WARN/FAIL); healthy() is true iff no FAIL findings

HardenedCollection.providerClassName() added so the report can name the
provider implementation without exposing the provider itself.

module-info.java: requires java.management (for ManagementFactory).

Tests (7 new): without canary returns full report; flags weak provider
as WARN; round-trips a freshly written canary; missing canary marks
report UNHEALTHY; toReport contains key diagnostics; toMap is JSON-
shaped; null collection rejected.

74 hardened tests pass total; zero regressions.

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
Operators adopting the hardened layer on an existing keyring need a path
to convert pre-existing plain items into hardened envelopes. This is the
sole API in the library that mutates items the hardened layer did not
write -- otherwise refused by the non-destructive design -- so it is
explicitly dual-gated:

  1. Builder.allowMigration(true) -- code-side, visible in PR review.
  2. Environment variable SECRET_SERVICE_HARDENED_ALLOW_MIGRATION=1 --
     ops-side, requires an explicit deploy-time action separate from
     the code change. Either gate alone is insufficient.

API
- HardenedCollection.migrateNonHardenedToHardened(Predicate<MigrationCandidate>)
  Iterates every item in the wrapped collection that lacks
  hardened.version=1 (and skips the EpochKeystore item); offers each
  to the predicate as a MigrationCandidate(path, label, attributes);
  on a true verdict, reads the plain text, writes a fresh hardened
  envelope, then deletes the plain original.
- Per-item failures are recorded in MigrationReport and DO NOT abort
  the batch. The plain original is deleted only after the hardened
  copy is durably written -- a crash in between leaves a
  transient duplicate (logged as a WARNING) but never loses data.
- Returns MigrationReport(migrated, skipped, failed, results) for ops
  reporting.
- New constants: ENV_ALLOW_MIGRATION, MigrationCandidate, MigrationResult,
  MigrationReport (all public records).

Internals
- migrateInternal(...) holds the body; migrateNonHardenedToHardened
  applies the dual gate then delegates. A package-private
  migrateNonHardenedToHardenedForTest hook bypasses the env-var gate
  (we cannot mutate process env from JUnit portably) but keeps the
  builder-side gate so the contract is still pinned by tests.

Tests (3 new)
- migrateRefusesWithoutBuilderFlag: SecurityTheaterException without
  the builder call.
- migrateRefusesWithoutEnvVar: SecurityTheaterException with the
  builder call but no env var (skipped if the test env actually has
  the var set).
- migrationBodyConvertsPlainItemsToHardened: pre-seed three plain
  items, predicate selects two, verify migrated=2, skipped=1, failed=0,
  plain originals gone for the matched items, plain remains for the
  unmatched item, and two hardened items appear with original
  attributes preserved.

77 hardened tests pass total; zero regressions.

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
Documents the substantial 3.0 reorganisation:

- Maven reactor split into three artifacts at one version (core 17 +
  hardened 21 + hardened-tpm2 21). Core consumers upgrade by version
  only; opt-in artefacts pull no extra transitive deps for non-users.
- Hardened layer: HardenedCollection decorator API, KeyMaterialProvider
  SPI extending AutoCloseable, real X25519 + ML-KEM-768 wiring with
  persisted EpochKeystore + forward-secret rotateEpoch, Argon2id pepper
  stretching, structured /healthz HardenedHealthCheck, dual-gated
  migrateNonHardenedToHardened.
- Hardened-tpm2 layer: Tpm2KeyMaterialProvider, Tpm2Provisioner CLI
  with four password sources + four pepper sources (cross-host escrow
  support), Tpm2SealedBlob versioned format, Tpm2Availability preflight,
  DA-lockout-enabled seal template.
- Docs: 1500-line threat_models_and_mitigation.md (threat catalogue,
  defense inventory, D-Bus policy, KeePassXC stacking, distribution
  formats, mitigation matrix, samples, backup/escrow/recovery, "Do I
  need this?", anti-checklist) + 10-example usage_examples.md.
- dbus-java 4.3.1 -> 5.2.0 (handled in MessageHandler).
- Removed: Tpm2Provisioner --password <plaintext> (leaked via
  /proc/<pid>/cmdline).

The 2.x line remains on develop-2.x.x as a single-artifact distribution
for consumers who don't want the module split.

https://claude.ai/code/session_0138rokvj7gqn1wved5nizRi
Main added search(), getProvider() and detectProvider() to
CollectionInterface; provide in-memory equivalents in the test fake.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RgemKZZ1H4ykGvQmWUBmtz
createItem called provider.currentStep() twice -- once inside
totpCodeForWrite() for DEK derivation and once when storing the
hardened.totp.step attribute. If the 30-second step boundary rolled
over between the two calls, the stored step no longer matched the
derivation step and the item became silently, permanently
undecryptable.

Capture the step once at the top of createItem and use the same value
for both derivation and the attribute. Regression test uses a provider
whose step advances on every currentStep() call, so any double-call
during write breaks the round-trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RgemKZZ1H4ykGvQmWUBmtz
swiesend added 25 commits July 4, 2026 10:07
Raise the build/runtime baseline to JDK 25 for every module: core,
hardened, and hardened-tpm2 now compile with --release 25 (core was 17,
the hardened modules were 21). This is a breaking change for consumers of
the core secret-service artifact.

Post-quantum ML-KEM-768 now uses the stock SunJCE provider via the
standard javax.crypto.KEM API (JEP 496, final in JDK 24), so the
post-quantum KEM no longer depends on BouncyCastle:

  - PqProviderBootstrap drops the reflective BouncyCastle registration and
    the ML_KEM_768_NAMES probing; it is now a small cached availability
    probe over KEM.getInstance("ML-KEM-768").
  - HybridKem generates the ML-KEM keypair directly via
    KeyPairGenerator.getInstance("ML-KEM-768"), no MLKEMParameterSpec
    reflection.
  - module-info keeps `requires static org.bouncycastle.provider` only for
    the optional Argon2KeyMaterialProvider (Argon2id has no JDK-native
    implementation); BouncyCastle stays a provided/optional dependency
    scoped to Argon2 alone.

PqProviderBootstrapTest is rewritten to assert ML-KEM-768 is served by a
stock JDK provider (not BouncyCastle) and that the probe reports the truth.

Docs updated to the JDK 25 floor (README coordinates, CHANGELOG, AGENTS.md,
usage examples, threat model) and the CI provider images already build on
JDK 25.

Full reactor green on Temurin 25.0.3: core 170, hardened 90 (post-quantum
tests now run natively instead of skipping), hardened-tpm2 24.
Static security review of the hardened and hardened-tpm2 modules at the
current alpha. Documents whether the layer delivers its promised
mechanisms and whether the underlying premise (application-layer
encryption on top of the OS keyring) is sound.

Findings under docs/audits/2026-07-security-audit-hardened.md:
  - Delivery: correct AEAD usage, correct hybrid X25519+ML-KEM combiner,
    a real forward-secrecy mechanism, and strong fail-safe/zeroing
    hygiene; gaps are anti-rollback off by default (null anchor),
    forward secrecy undone by retained backups, partial AEAD associated
    data, a non-idiomatic (but sound) DEK KDF, and theater-grade TOTP.
  - Premise: sound as B/C/D defense-in-depth with an independent pepper
    root, but structurally unable to defend the same-UID (class A)
    threat the README leads with; the real value is concentrated in the
    TPM path plus a deployment posture the library cannot enforce.

No confidentiality/integrity break of a correctly-configured TPM-backed
deployment. Recommendations are consolidated and mapped to finding IDs.
The TPM anti-rollback anchor never functioned. defineGenerationCounter,
the Tpm2GenerationAnchor constructor, and the anchor test's cleanup built
the NV handle with TPM_HANDLE.NV(nvIndex), passing the full NV handle
(e.g. 0x01800200, the value the public API and CLI document). But
TSS.Java's TPM_HANDLE.NV(x) computes 0x01000000 + x and expects a 24-bit
index offset, so a full handle double-applies the NV base
(0x01800210 -> 0x02800210, handle-type byte 0x02 = HMAC session) and
every NV_DefineSpace/NV_Increment/NV_Read was rejected with TPM_RC_VALUE.

Construct the handle directly from the documented full-handle value with
new TPM_HANDLE(nvIndex) at all three call sites, with a comment on the
offset-vs-handle trap. Verified end-to-end against a software TPM
(ibmswtpm2 tpm_server on localhost:2321) on JDK 25: Tpm2GenerationAnchorTest
now passes 4/4 (define, increment, monotonicity, value-survives-reopen).
The bug was invisible in CI because the NV tests assumeTrue(simulator)
and skip when none is listening.

Also align the seal/unseal round-trip test with the text-oriented pepper
SPI: it sealed a random binary pepper, but getPepper() UTF-8-decodes the
unsealed bytes (as HardenedCollection re-encodes for HKDF), so non-UTF-8
peppers are lossy. Seal a realistic base64/ASCII pepper instead, matching
what real providers emit.

Record both findings (F-8 NV handle bug, F-9 binary-safety) and the
dynamic-testing note in the hardened security audit.
…,F-5,F-6,F-7)

Non-breaking hardening from the security audit:

- F-1: warn loudly when forward secrecy is relied upon without anti-rollback
  protection -- at construction when enablePostQuantum(true) is set with no
  GenerationAnchor, and in rotateEpoch() when no anchor is configured. Without
  an anchor a keyring-writer can reintroduce a destroyed epoch and undo the
  guarantee. Also surface anchor presence in the posture log line.
- F-2: correct the forward-secrecy Javadoc to state the guarantee is bounded by
  the store's delete semantics and by backup-retention rotation (otherwise
  theoretical), and by configuring a GenerationAnchor against keystore rollback.
- F-5: warn at construction when the provider mode is STORED_STEP (it adds no
  security -- the step travels in cleartext next to the ciphertext and the seed
  co-locates with the pepper); strengthen the Mode enum Javadoc.
- F-6: scrub cached material on close() in EnvVar/File/Interactive providers
  (getPepper now fails closed after close); propagate close() through the
  NoTotp decorator and forward currentStep(); record whether the File provider's
  POSIX 0600/owner check actually ran and degrade its ThreatCoverage to all-NONE
  on a non-POSIX filesystem instead of claiming crossUid=REAL on trust.
- F-7: promote a provider sameUid=NONE finding from WARN to FAIL so a
  weak-but-decrypting deployment reports UNHEALTHY -- "healthy" now means
  hardened-and-decrypting, not merely decrypting.

Tests: NoTotp close-propagation/currentStep forwarding, EnvVar close scrubbing,
health-check FAIL on sameUid=NONE. Full hardened suite green (93 tests).
Tpm2Provisioner.seal() now base64-encodes the pepper before sealing, so ANY
input bytes round-trip losslessly through the text-oriented pepper SPI
(getPepper() returns a char[] that HardenedCollection re-encodes as UTF-8).
Previously a non-UTF-8 pepper was silently corrupted on unseal (a random
32-byte pepper decoded to 31 chars); real usage only sealed base64/ASCII, which
masked the bug. seal() now owns the encoding, and randomPepperSource() returns
raw entropy rather than pre-encoding (no double-encode). The provider's
effective pepper is base64(sealed input) -- deterministic and full-entropy.

Sealed blobs are bumped to v2; v1 blobs (verbatim, not binary-safe) are rejected
on read with guidance to re-provision, rather than silently yielding a pepper
that differs from a freshly-sealed one. Existing sealed peppers must be
re-provisioned (accepted; the module is alpha).

Verified end-to-end against a software TPM (ibmswtpm2 on localhost:2321):
sealUnsealRoundTripViaSimulator now seals raw random binary and asserts the
provider yields exactly base64(input); a v1-rejection test guards the migration.
hardened-tpm2 suite green (25 tests).
… (F-3, F-4)

F-3 -- stop trusting mutable D-Bus attributes for security-relevant values:
  - Bump the item envelope to VERSION_2, embedding the item id and the TOTP
    mode/step as authenticated header fields. fromBytes accepts only v2 and
    rejects the alpha v1 format with a clear message (clean break; the module
    is alpha and no persisted-format test pins v1).
  - AES-GCM associated data now covers the entire header (version, flags,
    kem_id, salt, epoch, item-id, TOTP mode/step, kem_ct, nonce) via
    Envelope.associatedData(), so tampering any header field -- or relocating a
    ciphertext under another item's attributes -- fails authentication instead
    of steering decryption. The read path sources item-id and TOTP mode/step
    from the envelope, never from attributes; the hardened.* attributes remain
    only as non-authoritative index metadata.

F-4 -- textbook HKDF shape: all secret inputs (pepper, KEM shared secret, TOTP
  code) are concatenated, length-prefixed, into the HKDF input keying material
  and mixed by Extract(salt, IKM); only public per-item context (domain tag,
  epoch, item id) goes into Expand's info. Previously the KEM/TOTP secrets sat
  in Expand's info, which was sound but relied on HMAC-with-known-key preimage
  resistance. Domain tag bumped to .../v2.

Tests: envelope item-id/TOTP round-trip, associatedData==header prefix, v1 and
invalid-totp-mode rejection, and a header-tamper test asserting a flipped flags
byte fails authentication. Full hardened suite green (97 tests).
…tion

- README hardened section now leads with the honest scoping: no same-UID
  (class-A) defense is possible in-process; the real value is concentrated in
  the TPM-sealed pepper plus measured boot + a MAC policy + backup-retention
  rotation, and the root of trust is the pepper (P-1, P-2).
- CHANGELOG: a Security block under the unreleased hardened work records the
  envelope v2 authentication + KDF change, the TPM NV handle fix, binary-safe
  TPM pepper, the health-check FAIL, and the provider hygiene/warnings.
- Security audit: a resolution-status table maps every F-1..F-9 finding to its
  fix and records the maintainer decisions (v2 clean break, base64-internal
  TPM pepper, sameUid=NONE -> FAIL).
…path

Promote the hardened-tpm2 end-to-end check into a @tag("system-test")
JUnit test (HardenedTpm2SystemTest). It drives the full stack against a
real platform TPM 2.0 and gnome-keyring over D-Bus:

  Tpm2Provisioner.seal -> Tpm2KeyMaterialProvider.forPlatformTpm
    -> HardenedCollection -> gnome-keyring

Coverage:
- pepper seals/unseals on the actual chip and a secret round-trips
  through a live keyring (raw stored value asserted to be an AEAD
  envelope, not plaintext);
- a wrong unseal password fails closed as a checked IOException;
- the provider advertises honest sameUid=PARTIAL / offline=REAL
  threat coverage with the MAC-policy rationale.

Safety: each test uses a uniquely named, non-default throwaway
collection created via master password and deleted in teardown; the
sealed-blob temp file lives under @tempdir; sealing uses only transient
TPM handles. Skips cleanly via Assumptions when no usable TPM is
reachable (CI containers, missing /dev/tpmrm0, caller not in the tss
group) or the provider is not gnome-keyring, and is excluded from the
default build (runs only under -Psystem-test).
Add a TPM System Tests workflow that runs the hardened-tpm2 end-to-end
system test against a software TPM 2.0 in CI.

TSS.Java's forPlatformTpm() only talks to a character device
(/dev/tpmrm0), not a TCP simulator, so the job backs /dev/tpmrm0 with
swtpm via the kernel vTPM proxy (tpm_vtpm_proxy -> /dev/vtpmx) and maps
the created device into the reused gnome-keyring provider container. The
test is unchanged — it opens /dev/tpmrm0 exactly as on real hardware.

The job is non-blocking (continue-on-error), matching the fragile
KWallet/KeePassXC legs, because tpm_vtpm_proxy availability depends on
the runner host kernel. It fails loudly rather than silently skipping:
it errors if /dev/vtpmx is absent, if swtpm reports no device, or if the
test does not run 3 passing, non-skipped tests. swtpm logs and surefire
reports are uploaded as diagnostics. Also runnable on demand via
workflow_dispatch.

The exact Maven invocation (mvn -B test -Psystem-test -pl hardened-tpm2
-am -Dtest=HardenedTpm2SystemTest -Dsurefire.failIfNoSpecifiedTests=false)
was verified against a real TPM 2.0.
Add a hardened.tpm2.backend system property to HardenedTpm2SystemTest:
- platform (default) -> forPlatformTpm() / real /dev/tpmrm0 (unchanged);
- simulator          -> forSimulator() / TCP reference simulator on
                        localhost:2321.

The seal supplier, provider factory, and reachability probe all route on
this flag; the simulator probe checks the socket only (opening a full TSS
session would power-cycle the simulator). When the selected backend is
absent the suite still skips cleanly via Assumptions.

Add a reference-simulator job to the TPM System Tests workflow that
builds the Microsoft ms-tpm-20-ref TPM 2.0 simulator, runs it on
localhost:2321, and drives the test with -Dhardened.tpm2.backend=
simulator over docker --network=host. It needs no kernel module, device,
or privilege, so it is the reliable (blocking) leg; the swtpm vTPM-proxy
job stays as the higher-fidelity, non-blocking leg. Both assert the test
ran 3 non-skipped tests so a missing TPM/keyring cannot go falsely green.

Verified locally: platform backend passes on real hardware (3/0/0/0);
simulator backend skips cleanly (3 skipped) when nothing listens on 2321.
Remove the swtpm-vtpm-proxy job. The ms-tpm-20-ref reference simulator
(localhost:2321, -Dhardened.tpm2.backend=simulator) is the sole TPM CI
backend: it needs no kernel module, device, or privilege and is the
reliable, blocking leg.

The test's platform backend (real /dev/tpmrm0) is unchanged and remains
the default for real-hardware runs; only the swtpm-based CI mechanism is
removed.
The simulator build failed in ./bootstrap with a 'possibly undefined
macro: AC_SUBST/AS_IF/AC_MSG_ERROR' cascade. Root cause: configure.ac
uses AX_PTHREAD (from autoconf-archive), which was not installed, so
autoreconf could not expand it. Add autoconf-archive to the build deps.
Capture the key operational insight downstream consumers kept missing:
with Tpm2KeyMaterialProvider the pepper is never stored at all -- at
rest there is only the TPM-wrapped blob, useless without the physical
chip plus the unseal password (DA-lockout rate-limited). The secret-
management problem therefore shrinks to how the unseal password reaches
the process at startup.

- README: add the reframe bullet to the hardened section, with the
  desktop ranking one-liner (prompt > login keyring > 0600 file; never
  argv/env) and a link to the new usage example.
- usage_examples.md §9.1: ranked desktop options with per-threat-class
  reasoning (prompt, login keyring, systemd creds with the >=256
  user-scope caveat, 0600 file, env-var never), why KeePassXC as the
  password store is an anti-pattern (availability coupling, co-location,
  and its human-in-the-loop benefit is had more simply by prompting),
  and the TPM access-control facts (tss group gating, authorization
  oracle not readable store, sameUid=PARTIAL needs a MAC policy).
- threat_models_and_mitigation.md §3.14: the same material in the
  defense-inventory template (what/coverage/how/limitations), stressing
  that the password is one of two factors so class C never rests on its
  handling alone, and that class A remains the job of MAC policy.
TOTP as a DEK factor never delivered what its name promised. STORED_STEP
was security theater in every configuration: the step travelled in a
cleartext attribute and an authenticated envelope field beside the
ciphertext, and the SPI co-located the seed with the pepper, so any
attacker holding the key material recomputed the factor -- equivalent to
concatenating a second pepper, with a misleading name. LIVE_CODE was only
a liveness window, never a possession factor: the SPI exposed the raw
seed to the process (getTotpSeed()), so a seed-holder bypasses the
+/-1-step read window entirely (and keyring item timestamps reveal the
write step regardless). Rather than keep an always-theater mode and a
niche liveness window behind a factor-shaped API, remove the whole thing.

Removed:
- Totp, NoTotpKeyMaterialProvider (+ their tests) and the
  totp-time-binding architecture doc.
- KeyMaterialProvider.Mode / getTotpSeed() / currentStep(); the SPI is
  now getPepper() + threatCoverage() + close().
- HardenedStatus.totpMode / timeBindingLabel().
- hardened.totp.mode / hardened.totp.step attributes; the STORED_STEP
  construction-time warning; Envelope TOTP flags and mode constants.
- Seed plumbing in EnvVar (SECRET_SERVICE_TOTP_SEED / _MODE), File
  (second line now ignored), Interactive (seed prompt), Argon2 and Tpm2
  providers.

Compatibility (deliberate):
- The envelope v2 byte layout is unchanged: the ex-TOTP header bytes
  (mode byte + step long) are now reserved-must-be-zero, and the DEK IKM
  keeps its zero-length third slot, so envelopes written without TOTP
  (the only honest mode) decrypt byte-identically.
- Envelopes written WITH a TOTP mode are rejected at parse with a clear
  message (their DEKs mixed a code this library can no longer derive);
  regression-pinned by rejectsEnvelopeWrittenWithTotp and
  preRemovalTotpEnvelopeFailsClosedWithClearMessage.

Verified: full reactor build on JDK 25; 108 hardened/hardened-tpm2 unit
tests green; HardenedTpm2SystemTest 3/3 against a real TPM 2.0 and live
gnome-keyring. CHANGELOG documents the removal; the 2026-07 audit F-5
resolution row now reads "Resolved by removal".
threat_models_and_mitigation.md (1,718 lines) is split at its section
boundaries into docs/security/ (overview, threat catalogue, defense
mechanisms, D-Bus policy, backend choice, full-disk encryption, desktop
deployment, CI deployment, sample configurations, backup & recovery,
anti-checklist & references). usage_examples.md is split by artifact
into docs/usage/{core,hardened,tpm2}.md. Numeric section prefixes are
dropped and every cross-reference is rewritten as a markdown link
(inside code blocks: plain-text titles), with anchors matching the
python-markdown slugifier MkDocs uses.

architecture/README.md becomes architecture/index.md (section index).
The old paths remain as short stubs pointing at the new pages so
existing GitHub links on main keep resolving. All in-repo referrers
(README.md, agents/AGENTS.md, architecture index) updated.

Also fixed while splitting: two leftover references to an internal
planning file in the references and threat-catalogue prose, a
cross-document link that pointed at the wrong section, and a note that
BouncyCastle is optional and Argon2-only.
vision.md described the functional API, dbus-java 5, KeePassXC support,
and CI as future 2.x work; all of it shipped in 3.0.0-alpha. Replace it
with docs/roadmap.md: what 3.0.0-alpha delivered, the genuinely open
items toward 3.0.0 stable (Maven Central publishing, provider-leg
stabilization, per-item unlock #45, reconnect story #52, container
detection #41, and the honest bar for any future possession factor),
the design invariants that bind every change, and a condensed 2.x
history. vision.md remains as a stub.
Add mkdocs.yml (Material theme, Mermaid via pymdownx.superfences,
changelog included from the repo root via pymdownx.snippets, explicit
nav, link/anchor validation raised to warnings so --strict fails on
breakage) and a Docs workflow: PRs touching the docs build the site
with mkdocs build --strict; pushes to main additionally deploy to
GitHub Pages (first run auto-enables Pages via configure-pages).

New site-only pages: a landing page distilled from README,
getting-started (coordinates + first secret), an audits index framing
audits as dated immutable snapshots, and the changelog include. The
old monolith filenames are excluded from the site (stubs are for
GitHub browsing only). site/ build output is gitignored.

Verified: mkdocs build --strict is clean (zero warnings) with Mermaid
blocks, the changelog include, and all internal links/anchors resolving.
Add a workflow that builds ibmswtpm2 (pinned rev 1682), starts tpm_server on
localhost:2321, and runs the hardened + hardened-tpm2 module tests with JaCoCo
coverage. These modules use an in-memory FakeCollection and the TPM simulator
only, so no Secret Service provider container is needed.

Until now the seal/unseal and NV generation-counter tests self-skipped in CI
(no simulator present) -- which is exactly how the F-8 anchor bug, where the
anti-rollback anchor never functioned, shipped undetected. This workflow makes
those tests actually execute on every push/PR and uploads the coverage report.
Introduce de.swiesend.secretservice.Hkdf, a small HKDF-SHA256 extract-then-expand
helper over the JDK's native javax.crypto.KDF / HKDFParameterSpec (final since
JDK 25, JEP 510). Convert all call sites -- core TransportEncryption (transport
session key), and the hardened deriveDek / EpochKeystore.deriveKek /
HybridKem.combine -- and drop the third-party at.favre.lib:hkdf dependency from
core and hardened (poms, dependencyManagement, and both module-info files).

One fewer runtime dependency, and the pseudo-random key now stays inside the JCE
provider rather than being materialized and zeroed by hand.

HKDF-SHA256 is fully deterministic (RFC 5869), so this is byte-identical to the
previous implementation -- proven by new RFC 5869 known-answer tests (Test Cases
1 and 2) plus a check that a null salt equals the RFC "absent salt" (HashLen
zeros) the KEM combine and transport paths rely on. Full hardened suite green
(100 tests); the transport path is additionally exercised end-to-end by the
gnome-keyring regression CI.
…ove TOTP

Envelope format v3 makes the AEAD and KDF agile like the KEM already was:
  - New authenticated aead_id and kdf_id header bytes (covered by the
    full-header AAD). aead_id 0x01=AES-256-GCM (default), 0x02=ChaCha20-Poly1305;
    kdf_id 0x01=HKDF-SHA256. Unknown ids round-trip through the parser and are
    rejected at decrypt time -- a new suite needs only a new id + dispatch
    branch, not a format bump. New Aead dispatch helper (JDK-native ciphers,
    both 32-byte key / 12-byte nonce / 16-byte tag) and a Builder.aead(AeadId)
    selector.
  - fromBytes accepts only v3 and rejects v1/v2 with a clear message. The dead
    KEM_ID_NONE write path is gone (kept only as a reserved constant).

Remove the TOTP subsystem entirely. In every provider the TOTP seed was read
from the same source as the pepper, so it never was an independent factor, and
STORED_STEP was self-admitted theater. Deleted Totp, the Mode/getTotpSeed/
currentStep SPI methods, the NoTotpKeyMaterialProvider decorator, the envelope
totp fields, and all provider seed handling; the DEK IKM is now pepper||kemSecret.

Also folds in two performance wins used by the new write path: a shared static
SecureRandom (was 3-5 fresh instances per createItem) and dropping an unused
intermediate buffer in charsToUtf8.

Full suites green on JDK 25: hardened 86, hardened-tpm2 25 (end-to-end against
the ibmswtpm2 simulator), including a ChaCha20-Poly1305 round-trip and v1/v2
rejection.
HardenedStatus.memoryLocked was advertised but hardcoded false -- mlock was never
implemented. Add MemoryLock, a best-effort mlockall(MCL_CURRENT|MCL_FUTURE) call
over the JDK 25 Foreign Function & Memory API, and a Builder.lockMemory(boolean)
opt-in (default off, because mlockall is a whole-process operation that can fail
on a low RLIMIT_MEMLOCK). When enabled, status().memoryLocked() reports the
actual syscall result -- true only if the lock took -- never a hardcoded value.
Failures (missing native access, low ulimit, non-POSIX) are logged and reported
as false rather than thrown. Silent operation needs
--enable-native-access=de.swiesend.secretservice.hardened.

Also fix epochCreated: it was Instant.now() computed per status() call; it now
tracks the real epoch creation/rotation time.
…rtial test

- EnvelopeFuzzTest: coverage-guided @fuzztest over Envelope.fromBytes (jazzer-junit,
  test scope only, never shipped). Arbitrary keyring bytes must never do worse than
  throw IllegalArgumentException; a successfully parsed envelope must re-serialize
  and expose its AAD. Runs a bounded corpus under mvn test; JAZZER_FUZZ=1 fuzzes
  continuously.
- AeadTest: pins the Aead suite dispatch -- AES-256-GCM and ChaCha20-Poly1305 each
  round-trip and match a direct JDK Cipher computation over the same key/nonce/AAD
  (proving the key-spec/param-spec/AAD wiring), AAD tampering fails authentication,
  and an unknown aead_id is rejected.
- HardenedCollectionTest: rotateEpoch straggler branch -- a partial rewrap failure
  makes rotateEpoch return false and keeps the previous epoch's keys, so the
  un-rewrapped item stays readable rather than being stranded.

Hardened suite green (94 tests).
HardenedBenchmark (JUnit @disabled by default) measures createItem/withSecret
rates over the in-memory FakeCollection, so it captures the per-item crypto cost
(hybrid KEM + HKDF + AEAD) without D-Bus I/O, across both AEAD suites. Run with
`mvn -pl hardened test -Dtest=HardenedBenchmark
-Djunit.jupiter.conditions.deactivate='org.junit*DisabledCondition'`.

Baseline on JDK 25 (this dev box, PQ off): ~3.1k createItem/s and ~6.0k
withSecret/s; ChaCha20-Poly1305 marginally ahead of AES-256-GCM. Complements the
shared-SecureRandom and buffer-reuse wins already landed with the v3 change.
- CHANGELOG: round-2 Security/Changed block (envelope v3 + aead_id/kdf_id +
  ChaCha20-Poly1305, TOTP removed, mlock via FFM, at.favre.lib:hkdf dropped for
  native javax.crypto.KDF, TPM-in-CI + Jazzer + KATs, perf).
- Audit: F-5 marked resolved-by-removal (the TOTP subsystem is gone).
- Architecture + usage + threat-model docs: drop the TOTP doc and all TOTP
  references (NoTotp decorator, getTotpSeed/mode, hardened.totp.* attributes,
  totpMode posture line); describe the selectable AEAD (AES-256-GCM /
  ChaCha20-Poly1305), the pepper||kemSecret IKM with public-context info, and
  the full-header AAD.
@swiesend
swiesend force-pushed the claude/secrets-encryption-design-daWnL branch from 8e4bfef to cb8af4b Compare July 26, 2026 21:00
swiesend added 4 commits July 26, 2026 23:27
…ve KDF)

Align the MkDocs security/usage pages, architecture docs, and README with the
shipped v3 hardened crypto:

- Selectable AEAD: everywhere the AEAD was described as fixed "AES-256-GCM" now
  reads AES-256-GCM (default) or ChaCha20-Poly1305 via Builder.aead(AeadId) --
  README, usage/hardened, architecture/index, security/{index,backend-choice,
  sample-configurations,backup-and-recovery}. (The keystore KEK stays AES-256-GCM;
  it is internal, not the selectable item AEAD.)
- Memory locking: defense-mechanisms / sample-configurations / desktop-deployment
  no longer say "mlockall via JNA, the consumer's job" -- they document
  Builder.lockMemory(true) (mlockall via the JDK FFM API), the
  --enable-native-access=de.swiesend.secretservice.hardened flag, RLIMIT_MEMLOCK,
  and that HardenedStatus.memoryLocked() reports the real result. Added a
  cipher-suite + JVM-hardening example to usage/hardened and a pointer from
  usage/tpm2 and the README.
- Native KDF: note HKDF-SHA256 runs on the native javax.crypto.KDF (JEP 510);
  removed the dropped at.favre.lib:hkdf from the README deps cell; added RFC 8439
  and JEP 510 to the reference list.
- Envelope v3: the architecture wire-format diagram now shows the aead_id/kdf_id
  selector bytes and item_id, and clarifies the SSv1 magic vs. the version byte
  (=3). Replaced a stale TOTP "step rollover" test-evidence row with the
  header-tamper, ChaCha round-trip, and fuzz tests.
- Custom-provider example: "two methods + optional close()" (was "four"), with a
  close() scrub added.
- Javadoc: corrected stale TOTP/AES-GCM wording in HardenedCollection,
  HardenedCollectionInterface, and HybridKem.
The rebase left two overlapping TPM workflows building two different simulators
from source: hardened-tpm2-tests.yml (ibmswtpm2, fast unit/integration tests) and
tpm-system-tests.yml (ms-tpm-20-ref, the live-daemon HardenedTpm2SystemTest).
Complementary coverage, redundant infrastructure.

Merge them into a single tpm-tests.yml that builds the ms-tpm-20-ref reference
simulator once on localhost:2321 and runs both scopes against it: the fast
hardened + hardened-tpm2 unit/integration tests (with JaCoCo) on the runner, then
the @tag(system-test) HardenedTpm2SystemTest in a gnome-keyring container. The
"assert Tests run: 3, Skipped: 0" silent-skip guard is retained so a missing
simulator or keyring can never make the job falsely green. Deletes the two old
workflows.
Declare the envelope v3 byte layout the frozen wire format for the release
line and pin it with a committed base64 regression fixture
(EnvelopeTest.v3WireFormatFixtureParsesToExactFields); a future format change
must allocate a new version byte rather than mutate v3. The alpha v1/v2
formats were never released and stay permanently rejected -- no read-compat
or migration engine. Documented in Envelope Javadoc, CHANGELOG, and
docs/architecture/envelope-encryption.md.

Fill the audit's untested thin spots:
- Migration accounting: a mid-run createItem failure records failed()==1 with
  the plain original left intact, and already-hardened items are counted in
  skipped() and not re-wrapped (HardenedCollectionTest).
- Anti-rollback anchor: an anchor whose read() throws fails loudly rather than
  silently proceeding, distinct from a below-floor snapshot which is refused
  gracefully -- the DoS-vs-rollback distinction (EpochKeystoreTest, FakeAnchor
  gains throwOnRead/throwOnAdvance).
- Thread-safety: FakeCollection is now backed by a ConcurrentHashMap with an
  AtomicBoolean failure flag; a cold-start concurrency test (8 threads x 25
  items) asserts every item decrypts under a single hardened.epoch. The
  wrapped-collection-bounded thread-safety contract is documented on
  HardenedCollection.
Two separate breakages in the consolidated TPM workflow's fast-test step:

- 'mvn -pl core,hardened install' does not install the aggregator pom
  de.swiesend:secret-service-parent, so the second, separate reactor could not
  read secret-service's artifact descriptor and failed with 'Could not find
  artifact de.swiesend:secret-service-parent:pom:3.0.0-alpha in central'.
  Restore the -am flag that the pre-consolidation workflow had.

- JaCoCo 0.8.12 cannot analyse Java 25 class files ('Unsupported class file
  major version 69'), so -Pcoverage failed in the report goal after all tests
  had passed. Every module compiles with release 25; bump to 0.8.15. This was
  latent -- no workflow ran -Pcoverage before this one.

Verified by reproducing both failures locally from a cold local repository and
re-running the exact two-command CI sequence: 99 hardened + 25 hardened-tpm2
tests pass, 0 skipped, and both JaCoCo reports generate.
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