version bumps, MAX_RECV_SEEN patch, FIPS interop, doc updates
Some checks failed
CI / lint (push) Failing after 1m16s
CI / test-python (push) Successful in 1m39s
CI / test-zig (push) Successful in 1m38s
CI / test (push) Has been cancelled
CI / test-wasm (push) Has been cancelled
CI / fuzz-regression (push) Has been cancelled
CI / miri (push) Has been cancelled
CI / build (push) Has been cancelled
CI / publish-wasm (push) Has been cancelled
CI / publish (push) Has been cancelled
CI / publish-python (push) Has been cancelled
Some checks failed
CI / lint (push) Failing after 1m16s
CI / test-python (push) Successful in 1m39s
CI / test-zig (push) Successful in 1m38s
CI / test (push) Has been cancelled
CI / test-wasm (push) Has been cancelled
CI / fuzz-regression (push) Has been cancelled
CI / miri (push) Has been cancelled
CI / build (push) Has been cancelled
CI / publish-wasm (push) Has been cancelled
CI / publish (push) Has been cancelled
CI / publish-python (push) Has been cancelled
This commit is contained in:
parent
89f5be4f40
commit
2198486bbf
16 changed files with 583 additions and 435 deletions
|
|
@ -302,6 +302,136 @@ pub fn verify(pk: &PublicKey, message: &[u8], signature: &Signature) -> Result<(
|
|||
}
|
||||
}
|
||||
|
||||
/// Sign with the FIPS 204 §5.2 ML-DSA.Sign envelope (domain separator +
|
||||
/// context string).
|
||||
///
|
||||
/// This is the **standard ML-DSA.Sign** algorithm — equivalent to what
|
||||
/// any FIPS 204-compliant implementation produces (rustls/aws-lc-rs,
|
||||
/// OpenSSL once they ship PQ, hardware tokens). Use it whenever a
|
||||
/// signature must verify under a generic ML-DSA-65 verifier.
|
||||
///
|
||||
/// Constructs `M' = 0x00 || len(ctx) || ctx || M` per FIPS 204 §5.2,
|
||||
/// then signs M' via the underlying primitive. Distinct from
|
||||
/// [`sign`], which signs M directly (no prefix) — soliton's
|
||||
/// non-standard mode used by intra-Lo signatures that are always
|
||||
/// verified by soliton itself.
|
||||
///
|
||||
/// `ctx` is the FIPS 204 context string. For TLS 1.3 use it MUST be
|
||||
/// empty (`b""`) per `draft-ietf-tls-mldsa`. Other protocol-specific
|
||||
/// contexts (e.g., `b"lo-session-bind"` for application-level domain
|
||||
/// separation) are supported up to 255 bytes.
|
||||
///
|
||||
/// Hedged signing: 32 bytes of fresh OS entropy mixed into the nonce
|
||||
/// derivation, identical to [`sign`]'s hedged behavior.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`Error::InvalidData`] if `ctx.len() > 255`
|
||||
/// - [`Error::InvalidLength`] if the secret-key seed length is wrong
|
||||
pub fn sign_with_ctx(sk: &SecretKey, message: &[u8], ctx: &[u8]) -> Result<Signature> {
|
||||
use ml_dsa::{B32, MlDsa65, Seed, SigningKey};
|
||||
|
||||
if ctx.len() > 255 {
|
||||
return Err(Error::InvalidData);
|
||||
}
|
||||
if sk.0.len() != SK_LEN {
|
||||
return Err(Error::InvalidLength {
|
||||
expected: SK_LEN,
|
||||
got: sk.0.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let seed: &Seed = sk.0.as_slice().try_into().map_err(|_| Error::Internal)?;
|
||||
let signing_key = SigningKey::<MlDsa65>::from_seed(seed);
|
||||
|
||||
// Hedged signing: 32 bytes of fresh getrandom entropy. Same
|
||||
// rationale as `sign()` — fault-injection resistance.
|
||||
let mut rnd_bytes = [0u8; 32];
|
||||
super::random::random_bytes(&mut rnd_bytes);
|
||||
let mut rnd: B32 = rnd_bytes.into();
|
||||
rnd_bytes.zeroize();
|
||||
|
||||
// FIPS 204 §5.2 envelope: M' = 0x00 || len(ctx) || ctx || M.
|
||||
// SHAKE's streaming absorb is associative, so passing M' as a
|
||||
// multi-chunk slice (`&[domain_sep, len_byte, ctx, message]`)
|
||||
// produces the same μ as concatenating into one buffer first.
|
||||
// The `MuBuilder::new(tr, ctx).message(M)` path used by the
|
||||
// ml-dsa crate's standard `verify_with_context` absorbs the
|
||||
// same bytes in the same order — so signatures from this
|
||||
// function verify under the standard verify path, AND the
|
||||
// standard sign path verifies under our `verify_with_ctx`.
|
||||
let domain_sep = [0x00u8];
|
||||
// `ctx.len() > 255` was rejected above, so this cannot fail.
|
||||
let len_byte = [u8::try_from(ctx.len()).map_err(|_| Error::Internal)?];
|
||||
let sig = signing_key.sign_internal(&[&domain_sep, &len_byte, ctx, message], &rnd);
|
||||
rnd.zeroize();
|
||||
|
||||
let sig_bytes = sig.encode().to_vec();
|
||||
assert_eq!(
|
||||
sig_bytes.len(),
|
||||
SIG_LEN,
|
||||
"ML-DSA-65 sig size mismatch: got {}, expected {SIG_LEN}",
|
||||
sig_bytes.len()
|
||||
);
|
||||
|
||||
Ok(Signature(sig_bytes))
|
||||
}
|
||||
|
||||
/// Verify with the FIPS 204 §5.2 ML-DSA.Verify envelope.
|
||||
///
|
||||
/// Counterpart of [`sign_with_ctx`]. Accepts signatures from any
|
||||
/// FIPS 204-compliant ML-DSA-65 implementation (when called with
|
||||
/// the same `ctx`).
|
||||
///
|
||||
/// `ctx` MUST match the context string used by the signer. For TLS
|
||||
/// 1.3 use that is `b""`.
|
||||
///
|
||||
/// Returns `Ok(())` on a valid signature, `Err(VerificationFailed)`
|
||||
/// for an invalid one or for `ctx.len() > 255`.
|
||||
pub fn verify_with_ctx(
|
||||
pk: &PublicKey,
|
||||
message: &[u8],
|
||||
ctx: &[u8],
|
||||
signature: &Signature,
|
||||
) -> Result<()> {
|
||||
use ml_dsa::{EncodedSignature, EncodedVerifyingKey, MlDsa65, VerifyingKey};
|
||||
|
||||
if ctx.len() > 255 {
|
||||
return Err(Error::VerificationFailed);
|
||||
}
|
||||
if pk.0.len() != PK_LEN {
|
||||
return Err(Error::InvalidLength {
|
||||
expected: PK_LEN,
|
||||
got: pk.0.len(),
|
||||
});
|
||||
}
|
||||
if signature.0.len() != SIG_LEN {
|
||||
return Err(Error::InvalidLength {
|
||||
expected: SIG_LEN,
|
||||
got: signature.0.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let vk_enc: &EncodedVerifyingKey<MlDsa65> =
|
||||
pk.0.as_slice().try_into().map_err(|_| Error::Internal)?;
|
||||
let vk = VerifyingKey::<MlDsa65>::decode(vk_enc);
|
||||
|
||||
let sig_enc: &EncodedSignature<MlDsa65> = signature
|
||||
.0
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| Error::Internal)?;
|
||||
let sig = ml_dsa::Signature::<MlDsa65>::decode(sig_enc).ok_or(Error::VerificationFailed)?;
|
||||
|
||||
// `verify_with_context` applies the FIPS 204 §5.2 envelope
|
||||
// (`0x00 || len(ctx) || ctx || M`) before verifying, matching
|
||||
// the construction in `sign_with_ctx`.
|
||||
if vk.verify_with_context(message, ctx, &sig) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::VerificationFailed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -335,6 +465,104 @@ mod tests {
|
|||
assert!(verify(&pk, b"", &sig).is_ok());
|
||||
}
|
||||
|
||||
/// `sign_with_ctx` round-trip with empty context (the form used
|
||||
/// for TLS 1.3 cert verify per draft-ietf-tls-mldsa).
|
||||
#[test]
|
||||
fn sign_with_ctx_empty_round_trip() {
|
||||
let (pk, sk) = keygen().unwrap();
|
||||
let sig = sign_with_ctx(&sk, b"hello fips204", b"").unwrap();
|
||||
assert!(verify_with_ctx(&pk, b"hello fips204", b"", &sig).is_ok());
|
||||
}
|
||||
|
||||
/// Round-trip with a non-empty context. Confirms the FIPS 204
|
||||
/// envelope `0x00 || len(ctx) || ctx || M` round-trips through
|
||||
/// the sign/verify pair.
|
||||
#[test]
|
||||
fn sign_with_ctx_nonempty_round_trip() {
|
||||
let (pk, sk) = keygen().unwrap();
|
||||
let sig = sign_with_ctx(&sk, b"payload", b"app-domain-v1").unwrap();
|
||||
assert!(verify_with_ctx(&pk, b"payload", b"app-domain-v1", &sig).is_ok());
|
||||
}
|
||||
|
||||
/// Domain-separation: a signature produced with one context
|
||||
/// MUST NOT verify under a different context. This is the
|
||||
/// security property that makes per-protocol context strings
|
||||
/// useful — a TLS-bound signature can't be replayed to an
|
||||
/// application-layer verifier and vice versa.
|
||||
#[test]
|
||||
fn sign_with_ctx_different_ctx_fails() {
|
||||
let (pk, sk) = keygen().unwrap();
|
||||
let sig = sign_with_ctx(&sk, b"payload", b"ctx-a").unwrap();
|
||||
assert!(matches!(
|
||||
verify_with_ctx(&pk, b"payload", b"ctx-b", &sig),
|
||||
Err(Error::VerificationFailed)
|
||||
));
|
||||
}
|
||||
|
||||
/// `sign` (no ctx) and `sign_with_ctx` produce DIFFERENT
|
||||
/// signatures, and the signatures don't cross-verify. This is
|
||||
/// the deliberate distinction: `sign` skips the FIPS 204
|
||||
/// envelope, `sign_with_ctx` applies it. If a regression ever
|
||||
/// made them equivalent, soliton's intra-Lo signatures would
|
||||
/// silently become FIPS 204-compatible (or vice versa) — both
|
||||
/// outcomes need to be intentional, not accidental.
|
||||
#[test]
|
||||
fn sign_and_sign_with_ctx_are_distinct() {
|
||||
let (pk, sk) = keygen().unwrap();
|
||||
let sig_internal = sign(&sk, b"payload").unwrap();
|
||||
let sig_standard = sign_with_ctx(&sk, b"payload", b"").unwrap();
|
||||
// Bytes differ (the rnd hedging makes equality unreliable
|
||||
// even for identical inputs, so this isn't a strict check).
|
||||
// The cross-verify check is the load-bearing one:
|
||||
assert!(
|
||||
matches!(
|
||||
verify_with_ctx(&pk, b"payload", b"", &sig_internal),
|
||||
Err(Error::VerificationFailed)
|
||||
),
|
||||
"internal-mode signature must NOT verify under FIPS 204 verify"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
verify(&pk, b"payload", &sig_standard),
|
||||
Err(Error::VerificationFailed)
|
||||
),
|
||||
"FIPS 204 signature must NOT verify under internal-mode verify"
|
||||
);
|
||||
}
|
||||
|
||||
/// 255-byte context is the spec maximum. Confirm it's accepted.
|
||||
#[test]
|
||||
fn sign_with_ctx_max_length_ok() {
|
||||
let (pk, sk) = keygen().unwrap();
|
||||
let ctx = vec![0xAB; 255];
|
||||
let sig = sign_with_ctx(&sk, b"payload", &ctx).unwrap();
|
||||
assert!(verify_with_ctx(&pk, b"payload", &ctx, &sig).is_ok());
|
||||
}
|
||||
|
||||
/// 256-byte context exceeds the FIPS 204 §5.2 limit and MUST
|
||||
/// be rejected at the API boundary rather than producing an
|
||||
/// invalid signature.
|
||||
#[test]
|
||||
fn sign_with_ctx_oversized_ctx_rejected() {
|
||||
let (_, sk) = keygen().unwrap();
|
||||
let ctx = vec![0u8; 256];
|
||||
assert!(matches!(
|
||||
sign_with_ctx(&sk, b"payload", &ctx),
|
||||
Err(Error::InvalidData)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_with_ctx_oversized_ctx_rejected() {
|
||||
let (pk, sk) = keygen().unwrap();
|
||||
let valid_sig = sign_with_ctx(&sk, b"payload", b"").unwrap();
|
||||
let too_long_ctx = vec![0u8; 256];
|
||||
assert!(matches!(
|
||||
verify_with_ctx(&pk, b"payload", &too_long_ctx, &valid_sig),
|
||||
Err(Error::VerificationFailed)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_wrong_message() {
|
||||
let (pk, sk) = keygen().unwrap();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue