Skip to content

Release 0.3.0: ML-DSA-87, COSE and the hash envelope; text under N6's own Unicode 15.0.0 rule, and a key function that returns a function refused - #23

Merged
HackTuah merged 1 commit into
mainfrom
release/0.3.0
Oct 21, 2026
Merged

HackTuah merged 1 commit into
mainfrom
release/0.3.0

Conversation

@HackTuah

Copy link
Copy Markdown
Member

The 0.3.0 release, with the fixes from a pre-release review. Three public modules ship: BeamMCP.Signer.MLDSA87, BeamMCP.Signer.COSE and BeamMCP.Signer.COSE.HashEnvelope (non-claimed). Ed25519 and core's BeamMCP.Signer behaviour are unchanged, so a 0.2 host moves by the requirement alone.

The review. Two independent lanes read every module added since 0.2.1, one on key handling and error surfaces and one on encoding and cryptographic correctness. They are recorded in the CHANGELOG. They are not a review under docs/security-review.md, whose definition is a person reading the code. What they found is fixed here:

  • A nested key function (Medium). A key function that returned a function was resolved twice. COSE computed the kid from the placeholder and returned a valid carrier whose signed kid was 48 zero bytes. Every key read now requires the key's bytes at their size ({:public_key, :not_2592_bytes}), so the kid is always the signing key's thumbprint.
  • The text rule (Medium). The encoder accepted any text the runtime's NFC normaliser left unchanged. N6 v0.1 §2.4 pins Unicode 15.0.0 and refuses format and bidirectional controls, unassigned code points and NFC quick-check MAYBE, and it calls no normaliser. An issuer such as "a‮b" (a right-to-left override) was signed.
    • text.ex applies §2.4 over unicode.ex, which tools/unicode_tables.sh generates from three Unicode data files it pins by sha256.
    • The tests read those files with a parser of their own and check all 1,112,064 scalar values and 20,000 random combining sequences.
    • Issuers are at most 65,536 bytes.
  • Low:
    • COSE judges the issuer before calling either key function.
    • A :crypto refusal while hashing is answered, not raised with the public key in the frame.
    • The encoder refuses an improper list.
    • sign_stream/2 refuses a function that is not an enumerable.
    • The census bars a normaliser, the process dictionary, ports and binary_to_term in every module.
  • Docs: sign_digest/2 cannot know its bytes are a digest; one key per scheme; when the seed is and is not held during a stream.
  • The 10 GiB test also bounds peak RSS, read from VmHWM after a reset, so there is no sampling gap.

Licensing. unicode.ex holds tables generated from the Unicode Character Database. The package lists Unicode-3.0 beside Apache-2.0, ships LICENSES/Unicode-3.0.txt, and NOTICE says so.

Evidence

  • Red first: the new tests run against main's code give 14 failures. A carrier comes back under a nested key function, the issuer's key functions are called first, a non-enumerable function is read as a stream, NFD text is :not_nfc where the rule says :nfc_maybe, and there are no Unicode tables.
  • Green: 92 tests on OTP 28.5.0.8 over OpenSSL 3.5.9 with Elixir 1.20.4, three runs; 96 over 3.0.13 with Elixir 1.18.4, ML-DSA tests excluded. Every CI step exits 0 locally, the REUSE lint included.
  • Mutants: twelve, each failing the suite. Each of the rule's five conditions dropped or loosened; the binary search off by one; the text limit one byte loose; a nested function accepted; the issuer judged after the keys; a byte-string issuer uncapped; an improper list raised on.
  • 10 GiB: VM memory growth 7.8 MB, RSS growth 2.8 MB, both against a 64 MiB limit.
  • Outside Elixir: the Go and Rust cross-verifiers pass a fresh sample.
  • Package: mix hex.build lists the eight lib/ files, README, CHANGELOG, LICENSE, NOTICE and LICENSES/Unicode-3.0.txt.

After merge: the maintainer tags v0.3.0 (signed), publishes with tools/release_tarball.sh v0.3.0 … --publish, and pushes the tag. The tag's provenance run attests the tarball and holds it to hex.pm's bytes.

… own Unicode 15.0.0 rule, and a key function that returns a function refused

Three public modules ship (MLDSA87, COSE, COSE.HashEnvelope); Ed25519
and the behaviour are unchanged, so a 0.2 host moves by the
requirement alone. Two independent review lanes read every module
added since 0.2.1 (key handling and error surfaces; encoding and
cryptographic correctness); what they found is fixed here.

- Keys: a key function that returned a function was resolved twice.
  COSE computed the kid from the placeholder and signed a carrier with
  a 48-byte zero kid. Every key read now requires the key's bytes at
  their size, so the kid is always the signing key's thumbprint.
- Text: the encoder followed the runtime's NFC normaliser. N6 v0.1
  section 2.4 pins Unicode 15.0.0 and refuses format and bidirectional
  controls, unassigned code points and NFC quick-check MAYBE, with no
  normaliser. lib/beam_mcp/signer/cose/text.ex applies that rule over
  lib/beam_mcp/signer/cose/unicode.ex, generated by
  tools/unicode_tables.sh from the three data files it pins by sha256.
  An issuer such as "a‮b" was signed and is now refused. Text and
  byte-string issuers are at most 65,536 bytes.
- COSE judges the issuer before calling either key function. A
  :crypto refusal while hashing is answered, not raised with the
  public key in the frame. The encoder refuses an improper list.
  sign_stream/2 refuses a function that is not an enumerable.
- Documented: sign_digest/2 cannot know its bytes are a digest; one key
  per scheme; when the seed is and is not held during a stream.
- Tests: the data files are fixtures, read by the tests' own parser.
  Every one of the 1,112,064 scalar values and 20,000 random combining
  sequences get the rule's verdict. The census bars a normaliser, the
  process dictionary, ports and binary_to_term in every module. The
  10 GiB test also bounds peak RSS (VmHWM, reset before the stream).
- Release: version 0.3.0, the CHANGELOG heading and review record,
  supported versions 0.3.x. The package lists Unicode-3.0 beside
  Apache-2.0 and ships LICENSES/Unicode-3.0.txt; NOTICE says so.

Measured locally: the new tests run against main's code give 14
failures (a carrier back under a nested key function, the issuer's
key functions called first, a non-enumerable function read, :not_nfc
where the rule says :nfc_maybe, no Unicode tables); 92 tests pass on
OTP 28.5.0.8 over OpenSSL 3.5.9 three times, 96 with the ML-DSA tests
excluded over 3.0.13. Twelve mutants of the new code each fail the
suite. The 10 GiB test passes (RSS growth 2.8 MB). Both cross-verifiers
pass a fresh sample.

Signed-off-by: Ayla Croft <aylacroft@proton.me>
@HackTuah
HackTuah merged commit e4e225e 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