Skip to content

[tracking] germ-integration — do not merge - #5

Draft
germ-mark wants to merge 13 commits into
mainfrom
germ-integration
Draft

[tracking] germ-integration — do not merge#5
germ-mark wants to merge 13 commits into
mainfrom
germ-integration

Conversation

@germ-mark

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

Copy link
Copy Markdown

Draft, permanently. This PR renders the running diff between main and the composed Germ stack.

No PR in this fork is ever merged. main is a fast-forward-only mirror of upstream, so merging anything into it would put Germ commits on the mirror and break that invariant. That applies to #4, #6 and #7 as much as to this one — they exist to give each change a reviewable diff and a discussion thread, not to land. Content leaves the fork by being submitted upstream to awslabs/mls-rs; germ-integration reaches consumers by being pinned.

See FORK.md.

Reconstruction order

germ-integration is defined by this recipe, not by its commit history. It takes only branch names, so it survives rebases:

git fetch origin
git checkout -B germ-integration origin/main

for range in \
  origin/main..origin/germ-crypto-providers \
  origin/main..origin/fix/cryptokit-build-rpath \
  origin/main..origin/feat/safe-extensions-exporter \
  origin/feat/safe-extensions-exporter..origin/feat/attachment-cek \
  origin/main..origin/ci/fork-ci
do
  git cherry-pick $(git rev-list --reverse --no-merges $range)
done

git cherry-pick <the FORK.md documentation commit>
Order Branch PR Commits
1 germ-crypto-providers #1, #2 (merged pre-resync) 4
2 fix/cryptokit-build-rpath #6 1
3 feat/safe-extensions-exporter #4 4
4 feat/attachment-cek #7 2
5 ci/fork-ci #8 1
6 FORK.md documentation 1

The fourth range is feat/safe-extensions-exporter..feat/attachment-cek, not main..#7 is stacked on #4 and carries its commits, so main.. would apply them twice.

Reconstruction is deterministic. Rebuilding from these five ranges reproduces the previous tree hash exactly. Verified twice while adding ci/fork-ci: each rebuild differed only in the files intentionally changed, nothing else moved. Current tree 7209a6ca…. Re-check with git diff --stat <previous> HEAD after any rebuild.

Auditing

Every commit here should be reachable from a feature branch. Compare by git patch-id --stable, not commit id — cherry-picking rewrites ids. The FORK.md commit is the one legitimate exception, since it describes the composition.

That audit is not decorative: fix/cryptokit-build-rpath rode on the shipped release pin for months without ever appearing in a PR, and was only found by running it.

Verification

CI now runs on this fork (#8) and is green here — all nine jobs on the PR run, seven on push with wasm/fuzz skipped by design. Before that, nothing in this repo had been machine-verified: Actions was disabled and total_count was 0 runs, including for the shipped release pin.

Covered on Linux and macOS: cargo hack build --each-feature across all 35 combinations · 435 default (legacy_interop ungated) · 464 safe_extensions · 524 --all-features · 464 under mls_build_async · 465 on TwoMLSPQ's exact feature set · 200 --no-default-features, 449 with rfc_compliant,safe_extensions · thumbv6m-none-eabi · cargo audit · wasm (395 lib tests + 3 crates) · six fuzz targets at 10k runs · clippy -D warnings in four configurations · fmt clean.

Still not covered: Windows, and the interop matrix (needs protoc and kotlin). mls-rs-crypto-awslc is excluded throughout — it builds aws-lc from source and TwoMLSPQ does not use it.

@germ-mark
germ-mark force-pushed the germ-integration branch 7 times, most recently from b5ed34c to c4e7dfc Compare July 28, 2026 19:18
mchenani and others added 13 commits July 28, 2026 12:27
Xcode 26 / the macOS 26 SDK report `librariesRequireRPath = true` for every
Apple target, including iOS, which tripped the guard in `configure()` and
failed the build outright. Shipping the cdylib inside an `@rpath/....framework`
is exactly the intended layout, so downgrade the panic to a warning and keep
emitting the Swift runtime link-search paths.

Also drop MIN_OSX_DEPLOYMENT_TARGET from 26.0 to 15.0: macOS ships the Swift
runtime in the OS, so a modern target needs no rpath, and 26.0 tripped the
same guard even for the host bindgen build.

Previously carried only on germ-shadow-safe-exporter (ec69dc2), outside any
feature branch.
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>
…tree

Implements the draft-sullivan-mls-attachments key schedule as a layer over
the exporter tree, rather than as a second copy of it. The exporter tree's
root is the application_export_secret, so no new epoch state is needed:

  CEK = ExpandWithLabel(SafeExportSecret(component_id),
                        ComponentOperationLabel(component_id, "attachment"),
                        object_id, 32)

- SecretTree::peek_leaf_secret derives a leaf from the deepest retained
  ancestor without mutating the tree, so a component whose references arrive
  after the fact stays derivable. Group::safe_export_secret keeps its
  consume semantics; a component should use one or the other.
- New group::attachment module: the -09 ComponentOperationLabel
  ("MLS Component", uint16, label<V>) — a different encoding from the
  older-draft struct in component_operation, which is unchanged — and the
  per-object CEK, object_id bounded to 1..=255 bytes.
- Group API: attachment_cek, attachment_cek_at_epoch, delete_component_secret.

insert_past_epoch no longer clears the exporter tree when archiving. That
clearing was correct while every reader consumed on export; attachment CEKs
need the epoch's tree for as long as the epoch is retained, and a prior epoch
already retains strictly more sensitive material in secret_tree. Anything
consumed or deleted during the epoch stays gone.

No serialized struct changes shape relative to the exporter tree commits, and
no cargo feature is enabled anywhere in the tree — consumers opt into
safe_extensions themselves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
legacy_interop is gated off when safe_extensions is on, because that feature
changed the EpochSecrets layout — so that build had no stored-state coverage
at all. Pin real blobs captured from germ-shadow-safe-exporter, the build
currently in production.

- The shipped Snapshot still loads and still yields attachment CEKs, which
  covers both the upstream resync and the attachment work on top of it.
- The shipped PriorEpoch decodes but declines to derive: that build cleared
  the exporter tree when archiving, so pre-upgrade epochs must degrade rather
  than return a wrong key.

Gated off under prior_epoch_membership_key, which appends a field to
PriorEpoch and so cannot decode blobs captured without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No commit in this fork — including the shipped release pin — had ever been
checked by anything but a laptop. Actions was disabled, and several upstream
workflows cannot run here regardless: Benchmarks on Merge assumes an IAM role
in awslabs' AWS account, Pull Request Slack Notifier needs their webhook, and
Native's coverage step needs their Codecov token.

Nothing here needs a credential. Nine jobs: tests on Linux and macOS (default,
safe_extensions, TwoMLSPQ's exact feature set, all-features), lint,
cargo-hack --each-feature, mls_build_async, a thumbv6m-none-eabi build,
security audit, wasm, and fuzz. The last three are folded in from upstream
workflows now disabled on this fork; disabling rather than deleting keeps
those files identical to upstream so they resync cleanly.

Feature-specific steps are gated on a probe of mls-rs/Cargo.toml, because
`safe_extensions` does not exist on every ref this can run against — not on
main, not on this branch, not on germ-crypto-providers. Without the gate every
such step fails with "the package 'mls-rs' does not contain this feature",
which would make the workflow_dispatch ref input useless for exactly the
branches worth checking with it.

Three things this had to get right, each found by a red run:

- `rustflags: ''` on every setup-rust-toolchain step. The action defaults
  RUSTFLAGS to `-D warnings`, which promotes pre-existing upstream warnings
  (unused imports in mls-rs and mls-rs-identity-x509) into build errors and
  failed three jobs for reasons unrelated to the code under test. Strictness
  belongs where the lint job puts it — passed to clippy explicitly.
- macOS on macos-26. mls-rs-crypto-cryptokit's cryptokit-bridge declares
  swift-tools-version 6.2; macos-14 ships Xcode 15.4 / Swift 5.10 and fails in
  the build script before compiling anything.
- No `wasm-pack test --test '*'`. client_tests exceeds ChromeDriver's 30s
  renderer timeout, the driver is SIGKILLed, and wasm-bindgen then fails to
  parse the truncated response as `missing field 'chunk'`. Not a code failure:
  the 395 wasm lib tests pass on the same target.

Triggers on push to germ-integration and on pull requests. Release pins are
frozen, so a workflow file cannot be added to one without moving it;
workflow_dispatch takes a ref input instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main mirrors upstream; each Germ change lives on its own feature branch rebased
onto it; germ-integration is their composition; release pins are immutable.

FORK.md records: that no PR in this fork ever merges, because main is a
fast-forward-only mirror and every PR exists only to give a change a reviewable
diff; the reconstruction recipe for germ-integration, expressed in branch names
and cherry-pick ranges so it survives rebases; which consumer turns on
safe_extensions and where; how to audit for commits belonging to no feature
branch; and why each release pin needs a captured state fixture.

The crate README gets a pointer only, so it keeps resyncing from upstream
cleanly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants