MetamorphicCrypto.Sign (metamorphic_crypto v0.8.0)

Copy Markdown View Source

Hybrid post-quantum signatures: ML-DSA (FIPS 204) + Ed25519 composite.

Thin, idiomatic wrappers over the audited metamorphic-crypto Rust core — the same primitives used by the browser WASM build, so a key derived or a signature verified here behaves byte-identically across native Rust, WASM, and this NIF.

Every message is signed by both a post-quantum algorithm (ML-DSA) and a classical one (Ed25519), and verification requires both component signatures to be valid (strict AND). An attacker must therefore break both a lattice scheme and an elliptic-curve scheme to forge a signature, and cannot strip one algorithm to downgrade the other — the length-framed, version-tagged wire format rejects signature-stripping and cross-protocol mix-and-match.

This is the signing counterpart to MetamorphicCrypto.Hybrid (ML-KEM + X25519 for confidentiality); here we combine ML-DSA + Ed25519 for authenticity / integrity. It is the foundational primitive for transparency logs and key-transparency work, where entries must be signed once and verified identically everywhere.

Security levels

ML-DSA is standardized by NIST at three parameter sets only — categories 2, 3, and 5 — and each is paired here with Ed25519:

LevelML-DSANIST CategoryEquivalentWire TagDefault
:cat2ML-DSA-442~AES-1280x01No
:cat3ML-DSA-653~AES-1920x02Yes
:cat5ML-DSA-875~AES-2560x03No

:cat3 is the default, mirroring this package's KEM default posture.

About the wire tags (namespacing note)

The leading tag byte is a per-artifact-type wire-format version, not a global NIST-category code. A signature tag only ever appears as the first byte of a signature / key blob produced by this module and is parsed only by verify/4 / derive_public_key/1; it is never handed to the KEM / seal code. Signatures and ciphertexts are distinct artifacts processed by distinct functions, so a signature tag can never be confused with a sealed-box or hybrid-KEM byte. The tags agree with the KEM tags on every level the two families share (:cat3 = 0x02, :cat5 = 0x03); they diverge only at 0x01 because NIST standardizes ML-KEM at categories {1, 3, 5} but ML-DSA at {2, 3, 5}, so "tag == category" cannot hold for both families.

Signing mode (hedged / randomized ML-DSA)

ML-DSA signatures use the hedged (randomized) variant from FIPS 204 — the standard's default and most conservative mode — which mixes fresh OS randomness into each signature. As a result the signature bytes are not reproducible (two signatures over the same message differ), but both verify. Ed25519 remains deterministic per RFC 8032.

The wire format is fully deterministic and pinnable: the layout, version tags, public-key derivation, and the domain-separation framing are all fixed. Tests must therefore pin derive_public_key/1 outputs, framing, and roundtrips — never raw signature bytes.

Domain separation (stable wire format)

Both algorithms sign the same domain-separated message, framed exactly like MetamorphicCrypto.Hash.sha3_512_with_context/2 (a length-prefixed context):

signed_msg = I2OSP(byte_size(context), 8) || context || message

where I2OSP(len, 8) is the UTF-8 byte length of context as a big-endian unsigned 64-bit integer. The 8-byte length prefix makes the (context, message) boundary unambiguous. context is a UTF-8 label, conventionally a versioned namespace — see sign_context_v1/0.

Byte layout

signature  = tag || ed25519_sig (64 B) || ml_dsa_sig (2420 / 3309 / 4627 B)
public_key = tag || ed25519_pk  (32 B) || ml_dsa_pk  (1312 / 1952 / 2592 B)
secret_key = tag || ed25519_seed(32 B) || ml_dsa_seed(32 B)              = 65 B

Encoding

Keys and signatures are base64 strings (consistent with the rest of this package and the WASM wire format). The message argument is a raw binary (the bytes you are signing), and context is a plain UTF-8 string.

Recovery hook

derive_public_key/1 re-derives the base64 public key deterministically from a base64 secret key. A secret recovered from backup (e.g. via MetamorphicCrypto.Recovery) therefore regenerates a byte-identical verifying key — so a pinned key (TOFU) stays the same across a recovery, and recovery will not false-trigger a key-change alert.

Security: secret key handling

Treat the secret_key as sensitive

The native core zeroizes secret seed material on drop. On the Elixir side the base64 secret_key is a regular binary and is not zeroized — keep it only as long as needed, store it encrypted at rest (see MetamorphicCrypto.Keys.encrypt_private_key/2), and avoid logging it.

Summary

Types

A hybrid ML-DSA + Ed25519 signing keypair, both fields base64-encoded.

Security level for a hybrid signing keypair.

CNSA 2.0 suite axis for signatures (orthogonal to level/0).

Functions

Re-derive the base64 public_key from a base64 hybrid secret_key.

Same as derive_public_key/1 but returns the public key directly, raising on invalid input.

Generate a hybrid signing keypair at the given security level (default :cat3).

Generate a hybrid signing keypair for the given suite/0 + level/0 (default :cat5).

Sign message under context with a base64 hybrid secret_key.

Same as sign/3 but returns the signature directly, raising on invalid input.

The recommended versioned context label for general-purpose signing: "metamorphic/sign/v1".

Report the {suite, level} posture declared by a base64 signing public key, without exposing the raw wire tag.

Report the {suite, level} posture declared by a base64 composite signature, without exposing the raw wire tag.

Verify a composite signature over message / context against public_key.

Types

keypair()

@type keypair() :: %{public_key: String.t(), secret_key: String.t()}

A hybrid ML-DSA + Ed25519 signing keypair, both fields base64-encoded.

level()

@type level() :: :cat2 | :cat3 | :cat5

Security level for a hybrid signing keypair.

  • :cat2 — ML-DSA-44 + Ed25519 (~AES-128)
  • :cat3 — ML-DSA-65 + Ed25519 (~AES-192), the default
  • :cat5 — ML-DSA-87 + Ed25519 (~AES-256)

suite()

@type suite() :: :hybrid | :hybrid_matched | :pure_cnsa2

CNSA 2.0 suite axis for signatures (orthogonal to level/0).

  • :hybriddefault & recommended. Existing ML-DSA + Ed25519 strict-AND composite, byte-for-byte unchanged (tags 0x01/0x02/0x03).
  • :hybrid_matched — opt-in. The classical partner is matched to the PQ category: Cat-2 → Ed25519 (identical to :hybrid), Cat-3 → Ed448 (tag 0x13), Cat-5 → ECDSA-P-521 hedged (tag 0x14).
  • :pure_cnsa2 — opt-in, Cat-5 only. Pure ML-DSA-87, no classical half — the NSA CNSA 2.0 signature box (tag 0x10). Standards-compliant but without the classical backstop the default :hybrid keeps until the lattice implementation is independently audited.

sign/3, verify/4, and derive_public_key/1 auto-detect the suite from the wire tag, so only keypair generation takes a suite argument.

Functions

derive_public_key(secret_key_b64)

@spec derive_public_key(secret_key_b64 :: String.t()) ::
  {:ok, String.t()} | {:error, String.t()}

Re-derive the base64 public_key from a base64 hybrid secret_key.

Both component public keys are a deterministic function of the secret seeds, so this reproduces exactly the public_key returned by generate_signing_keypair/1. Useful for recovering a public key from a backed-up secret, or for checking that a secret/public pair belong together. The security level is read from the secret key's wire tag.

Returns {:ok, public_key_b64} or {:error, reason} (invalid base64, wrong length, or unknown version tag).

Example

kp = MetamorphicCrypto.Sign.generate_signing_keypair()
{:ok, pk} = MetamorphicCrypto.Sign.derive_public_key(kp.secret_key)
# pk == kp.public_key

derive_public_key!(secret_key_b64)

@spec derive_public_key!(secret_key_b64 :: String.t()) :: String.t()

Same as derive_public_key/1 but returns the public key directly, raising on invalid input.

generate_signing_keypair(level \\ :cat3)

@spec generate_signing_keypair(level()) :: keypair()

Generate a hybrid signing keypair at the given security level (default :cat3).

Returns a map %{public_key: base64, secret_key: base64} using the documented byte layout. The two component algorithms are seeded from independent OS randomness.

Examples

kp = MetamorphicCrypto.Sign.generate_signing_keypair()
kp = MetamorphicCrypto.Sign.generate_signing_keypair(:cat5)

generate_signing_keypair_suite(suite, level \\ :cat5)

@spec generate_signing_keypair_suite(suite(), level()) ::
  {:ok, keypair()} | {:error, String.t()}

Generate a hybrid signing keypair for the given suite/0 + level/0 (default :cat5).

:hybrid (any level) and :hybrid_matched at :cat2 produce the existing ML-DSA + Ed25519 keys (byte-identical to generate_signing_keypair/1). The matched Cat-3/Cat-5 and pure suites produce the new combined-key layouts. sign/3 / verify/4 / derive_public_key/1 need no suite argument — they read it from the wire tag.

Returns {:ok, %{public_key: base64, secret_key: base64}}, or {:error, reason} for unsupported combinations (e.g. :pure_cnsa2 below :cat5).

Examples

# Pure CNSA 2.0 (ML-DSA-87, Cat-5 only)
{:ok, kp} = MetamorphicCrypto.Sign.generate_signing_keypair_suite(:pure_cnsa2, :cat5)

# Matched-strength hybrid (ML-DSA-65 + Ed448)
{:ok, kp} = MetamorphicCrypto.Sign.generate_signing_keypair_suite(:hybrid_matched, :cat3)

sign(message, context, secret_key_b64)

@spec sign(message :: binary(), context :: String.t(), secret_key_b64 :: String.t()) ::
  {:ok, String.t()} | {:error, String.t()}

Sign message under context with a base64 hybrid secret_key.

message is a raw binary, context is a UTF-8 label (use sign_context_v1/0 or another versioned string), and secret_key is base64. Produces a composite signature — a hedged (randomized) ML-DSA signature and a deterministic Ed25519 signature, both over the domain-separated frame(context, message). The security level is read from the secret key's wire tag.

Returns {:ok, signature_b64} or {:error, reason}. Because ML-DSA signing is randomized, signing the same message twice yields different (both-valid) signatures — never pin the signature bytes.

Example

kp = MetamorphicCrypto.Sign.generate_signing_keypair()
{:ok, sig} =
  MetamorphicCrypto.Sign.sign("log entry", MetamorphicCrypto.Sign.sign_context_v1(), kp.secret_key)

sign!(message, context, secret_key_b64)

@spec sign!(message :: binary(), context :: String.t(), secret_key_b64 :: String.t()) ::
  String.t()

Same as sign/3 but returns the signature directly, raising on invalid input.

sign_context_v1()

@spec sign_context_v1() :: String.t()

The recommended versioned context label for general-purpose signing: "metamorphic/sign/v1".

Pass this (or another versioned "namespace/purpose/vN" label) as the context argument to sign/3 / verify/4 to bind signatures to a purpose.

Example

iex> MetamorphicCrypto.Sign.sign_context_v1()
"metamorphic/sign/v1"

signature_posture(public_key_b64)

@spec signature_posture(public_key_b64 :: String.t()) ::
  {:ok, {suite(), level()}} | {:error, String.t()}

Report the {suite, level} posture declared by a base64 signing public key, without exposing the raw wire tag.

Composite artifacts produced by this package are self-describing: their leading version tag encodes which suite/0 and level/0 produced them. This is the typed, opaque decode of that contract — it lets a verifier learn the posture of a key it was handed and check it against an independently declared expectation (a "declared == observed" check), without re-deriving the private wire tags itself.

The full decoded blob length is validated against the expected length for the decoded posture (mirroring verify/4's length checks), so a truncated, over-long, or otherwise malformed key is rejected with {:error, reason} rather than silently misreporting a posture. An unknown/missing tag or invalid base64 is likewise {:error, reason}.

Returns {:ok, {suite, level}} or {:error, reason}.

Declared posture, not a verification result

This reports the declared format posture read from the artifact's tag and validated for length. It is not itself a cryptographic guarantee that a signature verifies — pair it with verify/4 for authenticity.

Cat-2 aliasing

:hybrid and :hybrid_matched are byte-identical at :cat2 (both tag 0x01), so a Cat-2 key canonically decodes to {:hybrid, :cat2}.

Example

{:ok, kp} = MetamorphicCrypto.Sign.generate_signing_keypair_suite(:pure_cnsa2, :cat5)
{:ok, {:pure_cnsa2, :cat5}} = MetamorphicCrypto.Sign.signature_posture(kp.public_key)

signature_posture_from_signature(signature_b64)

@spec signature_posture_from_signature(signature_b64 :: String.t()) ::
  {:ok, {suite(), level()}} | {:error, String.t()}

Report the {suite, level} posture declared by a base64 composite signature, without exposing the raw wire tag.

The signature counterpart to signature_posture/1; see that function for the self-describing-artifact contract, the Cat-2 aliasing note ({:hybrid, :cat2}), and the honest framing (declared posture, not a verification result). The full decoded length is validated against the expected signature length for the decoded posture, so a truncated/garbage/wrong-length signature is {:error, reason} rather than misreported.

Returns {:ok, {suite, level}} or {:error, reason}.

Example

kp = MetamorphicCrypto.Sign.generate_signing_keypair(:cat3)
{:ok, sig} = MetamorphicCrypto.Sign.sign("checkpoint", MetamorphicCrypto.Sign.sign_context_v1(), kp.secret_key)
{:ok, {:hybrid, :cat3}} = MetamorphicCrypto.Sign.signature_posture_from_signature(sig)

verify(message, context, signature_b64, public_key_b64)

@spec verify(
  message :: binary(),
  context :: String.t(),
  signature_b64 :: String.t(),
  public_key_b64 :: String.t()
) :: boolean()

Verify a composite signature over message / context against public_key.

message is a raw binary, context is the UTF-8 label used at signing time, and signature / public_key are base64. Returns true only if both the Ed25519 and ML-DSA component signatures verify (strict AND).

Returns false for any verification failure — a tampered message/context, a wrong key, a tampered half-signature, a cross-level or stripped artifact, or a malformed input (invalid base64 / unknown version tag). The function never raises and never returns an error tuple: a non-true result always means "not a valid signature", which is the safe default for a verifier.

Example

kp = MetamorphicCrypto.Sign.generate_signing_keypair()
{:ok, sig} = MetamorphicCrypto.Sign.sign("msg", "metamorphic/sign/v1", kp.secret_key)
true = MetamorphicCrypto.Sign.verify("msg", "metamorphic/sign/v1", sig, kp.public_key)