Skip to content

BeamMCP.Signer.COSE.HashEnvelope: a file of any size signed by its digest, the hash algorithm inside the signed bytes - #22

Merged
HackTuah merged 1 commit into
mainfrom
feat/cose-hash-envelope
Oct 21, 2026
Merged

HackTuah merged 1 commit into
mainfrom
feat/cose-hash-envelope

Conversation

@HackTuah

Copy link
Copy Markdown
Member

A new public module for artifacts too large to hold whole. Nothing in core changes, the callback stays one sign/2, and BeamMCP.Signer.COSE still exports only sign/2. Non-claimed: the signature is pure ML-DSA-87, not HashML-DSA, but no NSA, NIST or IETF text yet names the construction.

Why. COSE.sign/2 and MLDSA87.sign/2 take one binary, so a 10 GB clip, tile bundle or model file would have to be in memory whole; the first new test shows neither takes a stream. A raw signature over a digest would leave the hash algorithm unsigned and the digest indistinguishable from content. BeamMCP.Signer.COSE.HashEnvelope streams the artifact into SHA-384 and signs the digest as an RFC 9995 COSE Hash Envelope.

What it returns. The same N6 single-signer carrier as COSE's, with the digest as its payload: {0: 0xFFFF0003, 1: digest, 2: [protected, {}, null, signature]}.

  • Protected header: {1: -50, 2: [258], 4: kid, 16: typ, 258: -43, 259: preimage type}, plus 260: location when given. crit lists 258, so a COSE verifier that does not know hash envelopes refuses instead of taking the digest for content. Label 3 is never written.
  • Signature: pure ML-DSA-87 over ["Signature1", protected, h'', digest], made and checked by BeamMCP.Signer.MLDSA87.
  • The typ is the host's and required, because a hash envelope has no body and its typ alone names the kind of statement. It must be a lowercase media type ending +cose; the connectome's is refused. The preimage type (259) is per artifact. payload_hash: :sha512 gives -44.
  • Memory is one chunk and the hash state. Every option is judged before the first chunk, so a long stream is never hashed and then refused. A seed function is called only after the last chunk, so the seed is not held through the stream.

Evidence

  • Red first: the new tests fail before the module exists (21 failures, each an undefined module).
  • Green: OTP 28.5.0.8 over OpenSSL 3.5.9 with Elixir 1.20.4, three runs; and over 3.0.13 with Elixir 1.18.4, where the ML-DSA tests are excluded and the refusal runs before any chunk is read.
  • 10 GiB: a sparse 10 GiB file streamed in 1 MiB chunks in 14 s locally. The VM's memory grew 6.6 MB (the limit is 64 MiB), the digest equals sha384sum's, and a test-side verifier holding only the envelope and the public key validates it. CI's box leg runs it (mix test --only ten_gb).
  • Tampering: with any protected label or the digest changed, the signature is refused, here and in both cross-verifiers.
  • Mutants: eleven, and each fails the suite: crit dropped, 258 under another label, a digest length off by one, options judged after the stream, chunks dropped or a non-binary skipped, the seed read early, the 259 or 260 grammar loosened, +cose not required, the connectome typ admitted.
  • Outside Elixir: the cross-verify job reads the box leg's envelope with Go (fxamacker/cbor v2.9.6, crypto/mldsa) and Rust (ciborium 0.2.2, coset 0.4.2, aws-lc-rs). coset predates RFC 9995 and refuses the Sign1 over crit [258], the fail-closed refusal crit exists for; the Rust check asserts that, then verifies with coset's ToBeSigned. Both pass locally and both refuse a copy with one signature byte flipped. The job keeps its name, so the required check is unchanged.
  • Census: COSE now calls the shared private Keys for the option walk, the key read and the kid, and its answers are unchanged. The hash envelope's :crypto calls are the streaming hash and supports/1 only; it signs only through MLDSA87.sign/2 and does no I/O.

Not yet measured

  • The floor leg (OTP 27, Elixir 1.17), the 10 GiB test on the arm64 runner and the cross-verify job on CI. This run is their first with this change.

…gest, the hash algorithm inside the signed bytes

COSE.sign/2 and MLDSA87.sign/2 take one binary, so a 10 GB clip or
model file would have to be held whole; a raw signature over its
digest would leave the hash algorithm unsigned and the digest
indistinguishable from content. This module streams the artifact into
SHA-384 and signs the digest as an RFC 9995 COSE Hash Envelope, the
hash algorithm and the preimage's type in the protected header. It is
a non-claimed path.

- lib/beam_mcp/signer/cose/hash_envelope.ex: sign_stream/2 (any
  enumerable of binaries, hashed with :crypto.hash_init/update/final)
  and sign_digest/2. Outside the BeamMCP.Signer behaviour, which is
  unchanged; COSE still exports only sign/2. It returns the N6
  single-signer carrier with the digest as payload. The protected
  header is {1: -50, 2: [258], 4: kid, 16: typ, 258: -43 | -44,
  259: preimage type, ? 260: location}; label 3 is never written.
  typ and the preimage type are the host's and required; the
  connectome's typ is refused. Every option is judged before the
  first chunk is read, and a seed function is called only after the
  last. MLDSA87 signs and checks the ToBeSigned.
- lib/beam_mcp/signer/cose/keys.ex (private): the option walk, the key
  read and the kid, moved out of COSE and shared; COSE's answers do
  not change.
- Tests: the carrier and header read back strictly, in canonical
  order; a test-side verifier that holds only the envelope and the
  public key; every protected label, and the digest, changed and the
  signature refused; streamed equal to direct for SHA-384 and SHA-512;
  every refusal, and that none reads a chunk. A 10 GiB test (sparse
  file, tag ten_gb, run on CI's box leg) holds the VM's memory growth
  under 64 MiB and the digest to sha384sum's. The census covers both
  new modules.
- CI: the box leg runs the 10 GiB test; its sample adds a hash
  envelope, which the cross-verify job reads with Go (fxamacker/cbor,
  crypto/mldsa) and Rust (ciborium, coset, aws-lc-rs). coset 0.4.2
  predates RFC 9995 and refuses the Sign1 over crit [258], the
  fail-closed refusal crit exists for; the Rust check asserts it, then
  verifies with coset's ToBeSigned. The job keeps its name.
- Docs: README (a hash-envelope section), the assurance case,
  architecture, roadmap, the review checklist, CHANGELOG.

Measured locally: the new tests fail before the module exists (21
failures, each an undefined module) and pass after; the suite passes
on OTP 28.5.0.8 over OpenSSL 3.5.9 (Elixir 1.20.4, three runs) and
over 3.0.13 (Elixir 1.18.4, the ML-DSA tests excluded). The 10 GiB
file streamed in 14 s, the VM growing 6.6 MB. Eleven mutants of the
module each fail the suite. Both cross-verifiers pass the envelope and
refuse a copy with one signature byte flipped.

Signed-off-by: Ayla Croft <aylacroft@proton.me>
@HackTuah
HackTuah merged commit d04daca into main Oct 21, 2026
11 checks passed
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