Repository navigation
BeamMCP.Signer.COSE.HashEnvelope: a file of any size signed by its digest, the hash algorithm inside the signed bytes - #22
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A new public module for artifacts too large to hold whole. Nothing in core changes, the callback stays one
sign/2, andBeamMCP.Signer.COSEstill exports onlysign/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/2andMLDSA87.sign/2take 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.HashEnvelopestreams 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]}.{1: -50, 2: [258], 4: kid, 16: typ, 258: -43, 259: preimage type}, plus260: locationwhen given.critlists 258, so a COSE verifier that does not know hash envelopes refuses instead of taking the digest for content. Label 3 is never written.["Signature1", protected, h'', digest], made and checked byBeamMCP.Signer.MLDSA87.typis the host's and required, because a hash envelope has no body and itstypalone 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: :sha512gives -44.Evidence
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).critdropped, 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,+cosenot required, the connectometypadmitted.crypto/mldsa) and Rust (ciborium 0.2.2, coset 0.4.2, aws-lc-rs). coset predates RFC 9995 and refuses the Sign1 overcrit[258], the fail-closed refusalcritexists 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.Keysfor the option walk, the key read and the kid, and its answers are unchanged. The hash envelope's:cryptocalls are the streaming hash andsupports/1only; it signs only throughMLDSA87.sign/2and does no I/O.Not yet measured