Opt-in application-layer encryption: secret-service-hardened + hardened-tpm2 - #62
Open
swiesend wants to merge 69 commits into
Open
Opt-in application-layer encryption: secret-service-hardened + hardened-tpm2#62swiesend wants to merge 69 commits into
swiesend wants to merge 69 commits into
Conversation
swiesend
force-pushed
the
claude/secrets-encryption-design-daWnL
branch
from
April 27, 2026 21:05
7ddc48a to
f9253cf
Compare
swiesend
force-pushed
the
claude/secrets-encryption-design-daWnL
branch
2 times, most recently
from
July 3, 2026 22:03
279cab1 to
ac0d064
Compare
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
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RgemKZZ1H4ykGvQmWUBmtz
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
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
force-pushed
the
claude/secrets-encryption-design-daWnL
branch
from
July 26, 2026 21:00
8e4bfef to
cb8af4b
Compare
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ontomain, so it also carries the released 3.0.0-alpha functional API and the recent lock/flaky-test fixes.Architecture
de.swiesend:secret-service— JDK 17, the classical Secret Service 0.2 client (moved into acore/submodule; GAV unchanged for downstream consumers).de.swiesend:secret-service-hardened— JDK 21, the encryption layer (depends only on core + HKDF; BouncyCastle isrequires 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
HardenedCollectiondecorator over aCollectionInterface: AES-256-GCM envelopes with HKDF-derived per-item DEKs. API:createItem/withSecret/withSecrets/matchesSecret(constant-time, plaintext-free) /rotateEpoch/migrateNonHardenedToHardened.KeyMaterialProviderSPI (getPepper,getTotpSeed,mode,threatCoverage,close()), withEnvVarKeyMaterialProvider(CI/dev only, loud warning),FileKeyMaterialProvider,NoTotpKeyMaterialProvider, andArgon2KeyMaterialProvider(Argon2id pepper-stretching; EMBEDDED/INTERACTIVE/SENSITIVE profiles per RFC 9106).HybridKem— X25519 always-on, optionally + ML-KEM-768 via the standardjavax.crypto.KEMAPI (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;rotateEpochdestroys superseded epoch keys for forward secrecy.HardenedHealthCheck(structured /healthz-style diagnostic +toMap),ThreatCoverage(per-class A/B/C/DLevelratings),HardenedStatus, and a one-line startup posture log.Hardened-tpm2 layer
Tpm2KeyMaterialProvider.forPlatformTpm(...)/forSimulator(...)for a TPM-sealed pepper;Tpm2ProvisionerCLI (--password-{stdin,env,fd,prompt},--pepper-{stdin,env,fd}; no plaintext--password); versionedTpm2SealedBlob(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)
createItem(STORED_STEP write race);EpochKeystorecreate-then-delete + generation-based load;rotateEpochdestroys all superseded epochs; LIVE_CODE ±1-step read tolerance; migration reads into a zeroablechar[](notString).kem_idlabels; store the X25519 epoch public (keystore v2) so a loaded epoch is writable; graceful decode of unknown/old keystore formats.KemIdenum replaces bare byte constants;createItemreturnsOptional.empty()on crypto failure instead of throwing.GenerationAnchorSPI 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) anddocs/usage_examples.md(worked core / hardened / TPM examples).Testing
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.