- Promote the derive_secp256k1_path example from an ignored, non-compiling snippet to a running doctest (defines the seed it uses) - Restate the crate-level docs to the local-only dispatch model (ADR-025): the embedding binary assembles, no call-protocol wire; fix the Unlock/Lock method-name capitalization and disambiguate the ADR-038 reference (alknet-main seed-lifecycle decision) - List the cache module in the crate-level module organization - Update the CHANGELOG 0.2.0 Verified block (115 tests, README example compile/run-verified) and fold the doctest/doc fixes into the docs bullet Verification: 115 tests (all-features), 107 (default), clippy clean, cargo doc warning-free, fuzz/shared replay 8/8, publish dry-run clean.
6.6 KiB
Changelog
All notable changes to this crate are documented here. The format is based on Keep a Changelog, and this crate adheres to Semantic Versioning.
0.2.0 - 2026-11-04
The decrypt plaintext window fix (review 001, consumer input from
alkssh 0.1.0): VaultServiceHandle::decrypt now returns
Zeroizing<String> (the unlock_new precedent), so the decrypted
plaintext is zeroized on drop from the moment it materializes inside
the vault through to the moment the caller drops it. Previously the
plaintext materialized as a bare heap String (encryption::decrypt,
via String::from_utf8 on the AES-GCM plaintext) and crossed the API
boundary with no zeroization guarantee; consumers holding the
customer's secret (frequently private-key material, by construction,
since this vault holds the keys the family derives) could not close
that window from the consumer seat. The wrap is now the vault's job
and travels with the return value.
The in-crate instances of the same window are closed in the same
change: rotate's transient plaintext now transits through the
zeroizing return instead of a bare String, and the module-internal
encryption::decrypt zeroizes the decrypted byte buffer on every
path, including the UTF-8 validation failure path (which previously
returned the unzeroized bytes inside the error value before they were
dropped).
The EncryptedData wire format is untouched (ADR-018 holds): this is
return-type API surface only. One breaking change to the public API
(hence the minor bump), with the first consumer (alkssh) riding the
release;
alkssh's adapter-side Zeroizing::new wrap around the decrypt result
can be dropped against 0.2.0 (alkssh ADR-005 §3 pointer update is
their one-line doc touch).
Changed
decryptreturnsResult<Zeroizing<String>, VaultServiceError>(breaking API change).Zeroizing<String>derefs toString, so read-path call sites carry over unchanged; comparisons against&strneed.as_str()(or&*) for the explicitPartialEq. This matchesunlock_new's handling of the mnemonic: the same class of secret, same posture.parse_derivation_pathrejects hardened indices ≥ 2^31 instead of overflowing. The hardened-index encoding (index + 0x80000000) panicked onindex > 2147483647in debug builds and silently wrapped into a meaningless index in release builds (e.g.m/4294967295'). Such paths now returnDerivationError::InvalidPathon every profile.EncryptionKey::from_derived_bytesreturnsResult<Self, EncryptionError>(breaking API change). It panicked on derived material shorter than 32 bytes (&bytes[..32]with no length check); it now returnsEncryptionError::InvalidLengthand never panics. The two internal call sites (VaultServiceHandle:: encrypt/decrypt) already derive 32-byte keys and are unaffected beyond the?.encryption_path_for_versionrejects versions whose index overflows the hardened encoding. Versions above0x80000001previously produced paths (e.g.m/74'/2'/0'/2147483648') that the path parser rejects; such versions now returnDerivationError::InvalidPathdirectly. In-range versions produce identical paths.encryption::decrypt(module-internal) returnsZeroizing<String>and zeroizes all plaintext intermediates. The decrypted byte buffer is zeroized before an invalid-UTF-8 error surfaces, so a failed decryption no longer leaks the (malformed) plaintext through the error path.rotateinternal plaintext is zeroized. It consumes the zeroizingdecryptoutput; no bareStringtransit during re-encryption.KeyCachehonorsmax_entries = 0(cache disabled). The eviction path previously admitted one entry against a zero bound (len >= 0triggered an eviction from an empty order, a no-op, before the unconditional insert). Inserts are now no-ops under amax_entries = 0config; the key is dropped and zeroized. Found during the fuzzing-plan scope review (docs/plans/fuzzing.md).- Docs aligned: README usage example,
service.md§decrypt andencryption.md§module-internal signatures now document the zeroizing return; review 001 closed as resolved. Thederive_secp256k1_pathexample now runs as a doctest (wasignorewith an undefinedseed); crate-level docs restate the local-only dispatch model and list thecachemodule.
Verified
- 115 tests pass (
cargo test --all-features; 107 with default features);clippy --all-features --all-targetsclean.cargo publish --dry-run --all-features --allow-dirtybefore publish. The README usage example is compile- and run-verified against the published API surface. - Fuzzing introduced (
fuzz/, seedocs/plans/fuzzing.md): four libFuzzer targets on the untrusted-input surfaces, one binary per target infuzz_targets/:path_parse(path parser and version/device path helpers),encrypted_blob(EncryptedDatadeserialization plus the decrypt/rotate lifecycle),derived_key_json(DerivedKeyserde with the redacted-payload guard), andmnemonic_unlock(Mnemonic::from_phraseplus the unlock lifecycle). All invariant logic lives in thefuzz/sharedlibrary crate, so a stable toolchain replays the committed seed corpora through the same invariants (cargo test --manifest-path fuzz/shared/Cargo.toml); the fuzz binaries are nightly-only, pinned viafuzz/rust-toolchain.toml, and the fuzz workspace is excluded from both the root workspace and the published package. Detached campaigns for all four targets completed their budgets with zero crashes, OOMs, or timeouts. Findings: theparse_derivation_pathandencryption_path_for_versionoverflows above surfaced in the scope review before any campaign ran, as did themax_entries = 0cache bound bug above; all three are covered by regression tests.
0.1.0 - 2026-10-31
Initial release: BIP39 mnemonic generation (12/15/18/21/24 words) and
restore, SLIP-0010 Ed25519 HD derivation at the alket family paths
(PATHS::IDENTITY / SSH_HOST / ENCRYPTION plus Ethereum under
secp256k1), AES-256-GCM encryption of external credentials with an
HD-derived key (key_version ≥ 2, version-indexed derivation paths
per ADR-021), key rotation (rotate), and the VaultServiceHandle
lock/unlock lifecycle with a TTL/LRU key cache. EncryptedData is the
frozen wire format shared with alknet-storage and the TypeScript
@alkdev/storage consumer (ADR-018).