Safe Extensions exporter tree + application(3) PSKs (mls-extensions-08) for combiner-02 phase B - #4
Safe Extensions exporter tree + application(3) PSKs (mls-extensions-08) for combiner-02 phase B#4germ-mark wants to merge 4 commits into
Conversation
|
Applied all 13 findings from the xhigh code review in 5a17d9c: reset the exporter tree when demoting an epoch to PriorEpoch (key hygiene — it was retained where nothing could read it), added a KAT pinning the epoch_secret → "application_export" → leaf derivation, added a PSK value-mismatch test proving the PSK is mixed into the key schedule, redacted Debug output for psk_id, single-sourced the storage-key discriminant, documented the ExternalPskId namespace sharing and snapshot-growth characteristics, tightened the empty-tree guard to T::zero(), made application_psk() fail closed, and deduplicated the tree-descent walk and the PSK storage presence check. Suite: 430 tests green with the feature, 418 default, all-features and async-cfg clean, zero new warnings in the cargo-hack single-feature probe. 🤖 Generated with Claude Code |
Implement the forward-secure exporter of draft-ietf-mls-extensions-08 Section 4.4 behind a new (default-off) safe_extensions feature: * Derive application_export_secret = DeriveSecret(epoch_secret, "application_export") at the beginning of the epoch, alongside the other Table 4 secrets of RFC 9420. * Use it as the root of an Exporter Tree with 2^16 leaves that reuses the Secret Tree structure and ExpandWithLabel(., "tree", "left" | "right") derivation of RFC 9420 Section 9, materialized on demand via the existing SecretTree type. * Group::safe_export_secret(component_id) returns the leaf secret for a component. The secret is regarded as consumed once exported and source material is deleted per the RFC 9420 Section 9.2 deletion schedule; a second export in the same epoch fails. * Group::derive_secret exposes RFC 9420 DeriveSecret so callers can derive multiple independent values from one exported secret. draft -08 declares uint32 ComponentID but sizes the tree at 2^16 leaves (draft -09 narrows ComponentID to uint16). We keep the crate's existing u32 ComponentID and reject ids >= 2^16 rather than truncate. Enabling the feature adds a field to the serialized EpochSecrets, so stored group state is not portable across builds that differ in this feature. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implement psk_type = application(3) of draft-ietf-mls-extensions-08
Section 4.5 behind the safe_extensions feature:
* New public ApplicationPsk { component_id, psk_id } and a
JustPreSharedKeyID::Application variant with PSKType 3. The wire
format follows -08 (uint32 ComponentID); the external and resumption
variants stay byte-identical to RFC 9420, with codec tests pinning
all three encodings.
* CommitBuilder::add_application_psk mirrors add_external_psk.
* Application PSK values are resolved from the group's
PreSharedKeyStorage under ApplicationPsk::storage_id, the serialized
psktype and type-specific fields (0x03 || component_id || psk_id<V>),
so lookups are component-bound. Proposal validation checks storage
the same way it does for external PSKs.
* PreSharedKeyProposal::application_psk lets MlsRules::filter_proposals
classify pre-shared key proposals as external, application, or
resumption.
Together with Group::safe_export_secret this provides the primitives
required by draft-ietf-mls-combiner-02 Section 6.2 (apq_psk derivation
via SafeExportSecret and import as an application PSK), exercised by an
end-to-end commit test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Key hygiene and coverage: * Reset the exporter tree when demoting an epoch to PriorEpoch — it is only readable while its epoch is current, so retaining it kept deletable key material alive with no reader (RFC 9420 Section 9.2). * Pin the epoch_secret -> "application_export" -> leaf derivation in a known-answer test; member-agreement tests cannot catch a wrong label. * Add a value-mismatch test proving the application PSK is mixed into the key schedule (a member with a different stored value fails with InvalidConfirmationTag). Robustness and hygiene: * Redact psk_id bytes in ApplicationPsk's Debug via pretty_bytes, matching PskGroupId/PskNonce/ExternalPskId. * Derive storage_id by encoding JustPreSharedKeyID::Application instead of hardcoding the discriminant byte a second time. * Document that application PSK storage keys share the ExternalPskId namespace with app-chosen external PSK ids. * Use T::zero() (matching SecretTree::empty) instead of T::default() in take_leaf_secret's empty-tree guard. * Make PreSharedKeyProposal::application_psk match exhaustively so a future PSK type fails closed at compile time. * Document exporter-tree snapshot growth on Group::safe_export_secret and member-local divergence on Group::equal_group_state. Deduplication: * Extract SecretTree::take_leaf_node, sharing the root-to-leaf copath-consume walk between take_leaf_ratchet and take_leaf_secret. * Collapse the duplicated external/application storage presence check in filter_out_invalid_psks behind one Cow-keyed lookup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…aves add_application_psk accepted any u32 component_id, so a commit could reference an application PSK whose component_id has no leaf in the 2^16-leaf Exporter Tree -- a PSK no member could ever export to install. Reject an out-of-range component_id up front with MlsError::InvalidComponentId, matching the bound safe_export_secret already enforces on the export side. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
14454b3 to
4953b11
Compare
|
Rebased onto the post-resync Content is unchanged — the only diff between the two heads was upstream's own crate version bump (0.55.0 → 0.55.3). All four commits replayed with no conflicts, and #7 now bases on this branch so the two diffs no longer overlap. The PR description above still says the head is based on |
Implements the mls-extensions-08 Safe Extensions primitives that draft-ietf-mls-combiner-02 §6.2 requires for
apq_psk, unblocking TwoMLSPQ phase B (which currently ships a phase-A approximation built from two labeledexport_secretcalls +add_external_psk).Everything is behind a new default-off cargo feature
safe_extensions = ["psk"]. Default builds are unchanged (RFC 9420 behavior, wire format, and stored-state format are untouched; zero new warnings across the CI feature matrix).The four changes
Exporter Tree +
Group::safe_export_secret(component_id)(mls-extensions-08 §4.4)application_export_secret = DeriveSecret(epoch_secret, "application_export")is derived at the beginning of the epoch alongside the other Table-4 secrets (key_schedule.rs), so forward secrecy holds from the epoch's start secret.SecretTreemachinery: nodes are materialized on demand withExpandWithLabel(parent, "tree", "left" | "right", KDF.Nh)exactly as RFC 9420 §9, andSafeExportSecret(ComponentID)is thetree_node_secretat the component's leaf (newgroup/exporter_tree.rs).MlsError::ComponentSecretConsumed. Other components remain exportable. A new epoch has a fresh tree.Group::derive_secret(secret, label)exposes RFC 9420DeriveSecretso a caller can doDeriveSecret(exporter, "psk_id")andDeriveSecret(exporter, "psk")from a single export, matching the combiner-02 §6.2 figure ("The apq_exporter MUST be deleted after both the apq_psk_id and the apq_psk were derived" — the returnedSecretzeroizes on drop).psk_type = application(3)(mls-extensions-08 §4.5)ApplicationPsk { component_id, psk_id }andJustPreSharedKeyID::Applicationvariant with discriminant 3, encodingcomponent_idthenopaque psk_id<V>before the usualpsk_nonce.CommitBuilder::add_application_psk(component_id, psk_id)mirroringadd_external_psk. PSK values resolve from the existingPreSharedKeyStorageunderApplicationPsk::storage_id()=ExternalPskId(0x03 || component_id || psk_id<V>)(the serialized psktype + type-specific fields, without the nonce) — component-bound and collision-free against other application PSKs. Contract: every member inserts the PSK value under this key before committing/processing. Proposal validation checks storage presence the same way it does for external PSKs.Proposal classification for
MlsRules:PreSharedKeyProposal::application_psk()(alongside the existingexternal_psk_id()) letsfilter_proposalsaccept application PSKs while still rejecting resumption PSKs.Judgment calls (called out per the spec ambiguities)
uint32 ComponentIDvs 2^16 leaves: -08 declaresuint32 ComponentID(§4.1) but sizes the Exporter Tree at "2^16 leaves, corresponding to the 16 bits of a ComponentID value" (§4.4). Draft -09 resolves this by narrowingComponentIDtouint16. This fork keeps its existingu32ComponentID(also used by the safe-HPKE methods) and the -08 wire format, and rejects ids ≥ 2^16 (MlsError::InvalidComponentId) rather than truncating, which would collide distinct components onto one leaf. Callers should pick component ids < 2^16 (forward-compatible with -09).SafeExportSecretyet, so the KAT inexporter_tree.rsis self-pinned (cipher suite 1), plus a structural test proving the tree descent equals the MSB-first bit-walk (right/leftper bit) — the same derivation as the attachments branch (llm/sullivan-mls-attachments-pr-1f3641), so the two implementations agree onSafeExportSecretvalues. That branch intentionally keeps different lifetime semantics (retained root for cross-epoch CEK re-derivation) under its own feature; if it lands later it can reuseExporterTreefor the walk.safe_extensionsadds the exporter tree to the serializedEpochSecrets, so snapshots are not portable across builds that differ in this feature (same accepted tradeoff asprior_epoch_membership_key; thelegacy_interoptest is gated accordingly).Verification
cargo test -p mls-rs(default): 418 + 3 pass, incl.legacy_interopcargo test -p mls-rs --features safe_extensions: 428 + 3 pass (KATs, codec exact-bytes, member-agreement/consumption test, end-to-end application-PSK commit between two members, missing-PSK rejection)cargo test -p mls-rs --all-features: 488 + 18 + 6 passcargo clippy --all-targets(default,--features safe_extensions, and--all-features): cleancargo build --no-default-features --features safe_extensions(cargo-hack each-feature probe): compiles with zero new warnings vs the pre-existingpsk-only baselineRUSTFLAGS='--cfg mls_build_async'check + exporter tests: passcargo fmt --check: cleangerm-shadowbefore opening the PR.Upstream candidate
The head branch
feat/safe-extensions-exporteris based onorigin/main(09761fa, post-resync) with no Germ-specific coupling, in four self-contained commits (exporter tree; application PSKs; review fixes; component-id bound), each of which builds and tests green independently — suitable as-is for a PR toawslabs/mls-rs.Rebased from the pre-resync base
e96b484with no conflicts; the only content difference between the two heads was upstream's own crate version bump.Follow-up (PR E, TwoMLSPQ)
Pin the fork rev to the new
germ-shadowtip, enablefeatures = ["safe_extensions"], and swap the phase-A approximation for:safe_export_secret(component_id)→derive_secret(·, "psk_id")/derive_secret(·, "psk")→ insert value underApplicationPsk::storage_id()→add_application_psk(component_id, psk_id).🤖 Generated with Claude Code