Skip to content

Safe Extensions exporter tree + application(3) PSKs (mls-extensions-08) for combiner-02 phase B - #4

Draft
germ-mark wants to merge 4 commits into
mainfrom
feat/safe-extensions-exporter
Draft

Safe Extensions exporter tree + application(3) PSKs (mls-extensions-08) for combiner-02 phase B#4
germ-mark wants to merge 4 commits into
mainfrom
feat/safe-extensions-exporter

Conversation

@germ-mark

@germ-mark germ-mark commented Jul 11, 2026

Copy link
Copy Markdown

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 labeled export_secret calls + 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

  1. 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.
    • It roots an Exporter Tree with 2^16 leaves that reuses the existing SecretTree machinery: nodes are materialized on demand with ExpandWithLabel(parent, "tree", "left" | "right", KDF.Nh) exactly as RFC 9420 §9, and SafeExportSecret(ComponentID) is the tree_node_secret at the component's leaf (new group/exporter_tree.rs).
    • Consume semantics per the draft: once a component's secret is exported, parents were already deleted as children were derived (RFC 9420 §9.2 deletion schedule) and the leaf is deleted on return; a second export of the same component in the same epoch fails with MlsError::ComponentSecretConsumed. Other components remain exportable. A new epoch has a fresh tree.
    • Group::derive_secret(secret, label) exposes RFC 9420 DeriveSecret so a caller can do DeriveSecret(exporter, "psk_id") and DeriveSecret(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 returned Secret zeroizes on drop).
  2. psk_type = application(3) (mls-extensions-08 §4.5)

    • New public ApplicationPsk { component_id, psk_id } and JustPreSharedKeyID::Application variant with discriminant 3, encoding component_id then opaque psk_id<V> before the usual psk_nonce.
    • External and resumption variants stay byte-identical to RFC 9420; codec tests pin the exact bytes of all three variants.
  3. CommitBuilder::add_application_psk(component_id, psk_id) mirroring add_external_psk. PSK values resolve from the existing PreSharedKeyStorage under ApplicationPsk::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.

  4. Proposal classification for MlsRules: PreSharedKeyProposal::application_psk() (alongside the existing external_psk_id()) lets filter_proposals accept application PSKs while still rejecting resumption PSKs.

Judgment calls (called out per the spec ambiguities)

  • uint32 ComponentID vs 2^16 leaves: -08 declares uint32 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 narrowing ComponentID to uint16. This fork keeps its existing u32 ComponentID (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).
  • No official test vectors exist for SafeExportSecret yet, so the KAT in exporter_tree.rs is self-pinned (cipher suite 1), plus a structural test proving the tree descent equals the MSB-first bit-walk (right/left per bit) — the same derivation as the attachments branch (llm/sullivan-mls-attachments-pr-1f3641), so the two implementations agree on SafeExportSecret values. 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 reuse ExporterTree for the walk.
  • Stored-state compatibility: enabling safe_extensions adds the exporter tree to the serialized EpochSecrets, so snapshots are not portable across builds that differ in this feature (same accepted tradeoff as prior_epoch_membership_key; the legacy_interop test is gated accordingly).

Verification

  • cargo test -p mls-rs (default): 418 + 3 pass, incl. legacy_interop
  • cargo 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 pass
  • cargo clippy --all-targets (default, --features safe_extensions, and --all-features): clean
  • cargo build --no-default-features --features safe_extensions (cargo-hack each-feature probe): compiles with zero new warnings vs the pre-existing psk-only baseline
  • RUSTFLAGS='--cfg mls_build_async' check + exporter tests: pass
  • cargo fmt --check: clean
  • This merge was rebuilt and retested locally on top of germ-shadow before opening the PR.

Upstream candidate

The head branch feat/safe-extensions-exporter is based on origin/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 to awslabs/mls-rs.

Rebased from the pre-resync base e96b484 with 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-shadow tip, enable features = ["safe_extensions"], and swap the phase-A approximation for:
safe_export_secret(component_id)derive_secret(·, "psk_id") / derive_secret(·, "psk") → insert value under ApplicationPsk::storage_id()add_application_psk(component_id, psk_id).

🤖 Generated with Claude Code

@germ-mark
germ-mark requested a review from mchenani July 11, 2026 03:36
@germ-mark

Copy link
Copy Markdown
Author

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

germ-mark and others added 4 commits July 28, 2026 09:51
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>
@germ-mark
germ-mark force-pushed the feat/safe-extensions-exporter branch from 14454b3 to 4953b11 Compare July 28, 2026 17:25
@germ-mark
germ-mark changed the base branch from germ-shadow to main July 28, 2026 17:25
@germ-mark

Copy link
Copy Markdown
Author

Rebased onto the post-resync main (was e96b484, now 09761fa) and retargeted from germ-shadow to main. Force-pushed 14454b384953b11a; the old head is still reachable if anything needs recovering.

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 origin/main (e96b484); that's the only stale detail.

@germ-mark
germ-mark marked this pull request as draft July 28, 2026 18:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant