v0.0.19 - Implemented Phase 13 PQ crypto: v2 FIPS seeded derivation (BIP-32 for PQ coin types 102003'-102005') with ML-DSA-65, SLH-DSA-128s (SHA2), and ML-KEM-768 keygen/sign/verify/encaps/decaps; fixed sign-verb key truncation; added encapsulate/decapsulate verbs; cross-implementation parity verified against nostr_quantum_preparation v2 vectors
This commit is contained in:
Generated
+1
-1
@@ -2207,7 +2207,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "signer"
|
||||
version = "0.0.18"
|
||||
version = "0.0.19"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chacha20poly1305",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "signer"
|
||||
version = "0.0.18"
|
||||
version = "0.0.19"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Attended Nostr signing daemon — Rust port of n_signer"
|
||||
|
||||
@@ -207,21 +207,21 @@ All keys derive deterministically from the loaded BIP-39 mnemonic. The caller se
|
||||
|
||||
#### 4.4.1 Algorithm table
|
||||
|
||||
| Algorithm | Key type | FIPS standard | Derivation path | Key sizes (priv / pub, bytes) |
|
||||
|-----------------|-----------------|---------------|---------------------------------------|-------------------------------|
|
||||
| `secp256k1` | Signature | — | `m/44'/1237'/<n>'/0/0` (NIP-06) | 32 / 32 |
|
||||
| `ed25519` | Signature | — | `m/44'/102001'/<n>'/0/0'` (SLIP-0010) | 32 / 32 |
|
||||
| `x25519` | Key agreement | — | `m/44'/102002'/<n>'/0/0'` (SLIP-0010) | 32 / 32 |
|
||||
| `ml-dsa-65` | PQ signature | FIPS 204 | `m/44'/102003'/<n>'/0/0'` → DRBG | 4032 / 1952 |
|
||||
| `slh-dsa-128s` | PQ signature | FIPS 205 | `m/44'/102004'/<n>'/0/0'` → DRBG | 64 / 32 |
|
||||
| `ml-kem-768` | PQ KEM | FIPS 203 | `m/44'/102005'/<n>'/0/0'` → DRBG | 2400 / 1184 |
|
||||
| `otp` | One-time pad | — | (no key — bound USB pad) | n/a |
|
||||
| Algorithm | Key type | FIPS standard | Derivation path | Key sizes (priv / pub, bytes) |
|
||||
|-----------------|-----------------|---------------|----------------------------------------|-------------------------------|
|
||||
| `secp256k1` | Signature | — | `m/44'/1237'/<n>'/0/0` (NIP-06) | 32 / 32 |
|
||||
| `ed25519` | Signature | — | `m/44'/102001'/<n>'/0/0'` (SLIP-0010) | 32 / 32 |
|
||||
| `x25519` | Key agreement | — | `m/44'/102002'/<n>'/0/0'` (SLIP-0010) | 32 / 32 |
|
||||
| `ml-dsa-65` | PQ signature | FIPS 204 | `m/44'/102003'/<n>'/0'/0'` (BIP-32) | 32 / 1952 |
|
||||
| `slh-dsa-128s` | PQ signature | FIPS 205 | `m/44'/102004'/<n>'/0'/0'` (BIP-32) | 64 / 32 |
|
||||
| `ml-kem-768` | PQ KEM | FIPS 203 | `m/44'/102005'/<n>'/0'/0'` (BIP-32) | 64 / 1184 |
|
||||
| `otp` | One-time pad | — | (no key — bound USB pad) | n/a |
|
||||
|
||||
#### 4.4.2 Key derivation
|
||||
|
||||
- **secp256k1** uses standard BIP-32/NIP-06 derivation. The 32-byte path output is the private key scalar.
|
||||
- **ed25519 / x25519** use SLIP-0010 HMAC-SHA512 derivation (all-hardened paths, as required by SLIP-0010 for ed25519). The 32-byte output is the private key.
|
||||
- **PQ algorithms** (ML-DSA-65, SLH-DSA-128s, ML-KEM-768) use a two-stage approach: the mnemonic-derived 32-byte seed feeds a SHAKE-256 DRBG (NIST SP 800-90A style), which replaces the RNG during keygen. Same mnemonic, same index, same key pair every time. The PQ implementations are the pure-Rust crates [`ml-dsa`](https://crates.io/crates/ml-dsa), [`ml-kem`](https://crates.io/crates/ml-kem), and [`slh-dsa`](https://crates.io/crates/slh-dsa). The three post-quantum algorithms address the **harvest-now-decrypt-later** threat: an adversary recording encrypted traffic today to decrypt it once a quantum computer becomes available.
|
||||
- **PQ algorithms** (ML-DSA-65, SLH-DSA-128s, ML-KEM-768) use the **v2 FIPS seeded derivation** (see [`plans/pq_seeded_derivation_plan.md`](plans/pq_seeded_derivation_plan.md)): BIP-32 child bytes at the exact seed length required by each algorithm feed the seeded keygen APIs directly — no DRBG expansion. ML-DSA-65 takes one 32-byte child; SLH-DSA-128s takes two children concatenated (first 48 of 64 bytes, split as sk.seed ∥ sk.prf ∥ pk.seed); ML-KEM-768 takes two children concatenated (all 64 bytes, split as d ∥ z). Same mnemonic, same index, same key pair every time — and the same keys as the nostr_quantum_preparation web app (verified against its pinned test vectors by `tests/pq_conformance.rs`). PQ private keys are stored in seed form. The PQ implementations are the pure-Rust crates [`ml-dsa`](https://crates.io/crates/ml-dsa), [`ml-kem`](https://crates.io/crates/ml-kem), and [`slh-dsa`](https://crates.io/crates/slh-dsa) (SLH-DSA uses the SHA2-128s parameter set). The three post-quantum algorithms address the **harvest-now-decrypt-later** threat: an adversary recording encrypted traffic today to decrypt it once a quantum computer becomes available.
|
||||
- **otp** does not derive a key. A pad is bound at signer startup (`--otp-pad-dir` + `--otp-pad`); the pad offset advances monotonically across requests.
|
||||
|
||||
#### 4.4.3 OTP
|
||||
@@ -772,7 +772,7 @@ cargo test
|
||||
| [`src/key_store.rs`](src/key_store.rs:1) | BIP-32 / SLIP-0010 key derivation and storage |
|
||||
| [`src/mnemonic.rs`](src/mnemonic.rs:1) | BIP-39 mnemonic loading and seed derivation |
|
||||
| [`src/pq_crypto.rs`](src/pq_crypto.rs:1) | Post-quantum keygen (ML-DSA-65, SLH-DSA-128s, ML-KEM-768) |
|
||||
| [`src/pq_drbg.rs`](src/pq_drbg.rs:1) | SHAKE-256 DRBG for PQ keygen |
|
||||
| [`src/pq_drbg.rs`](src/pq_drbg.rs:1) | SHAKE-256 DRBG (retained port; not used for PQ keygen — see the v2 seeded derivation) |
|
||||
| [`src/alg_cache.rs`](src/alg_cache.rs:1) | Per-algorithm derived-key cache |
|
||||
| [`src/otp_pad.rs`](src/otp_pad.rs:1) | One-time pad binding, offset tracking, encrypt/decrypt |
|
||||
| [`src/miner.rs`](src/miner.rs:1) | NIP-13 proof-of-work mining for `nostr_mine_event` |
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# PQ Seeded Derivation Migration Plan (signer)
|
||||
|
||||
## Status
|
||||
|
||||
**Implemented** (signer). Companion to the v2 hardened-derivation design in
|
||||
`nostr_quantum_preparation/plans/v2-hardened-derivation.md`. That project is the
|
||||
only one with users; it keeps a v1→v2 migration path. signer (and n_signer)
|
||||
have no users, so we are free to align to the FIPS seeded interface directly.
|
||||
|
||||
Implementation notes (divergences from the original proposal, all consistent
|
||||
with its intent):
|
||||
|
||||
- `derive_pq_seed(mnemonic, coin, indices)` was realized as
|
||||
`derive_pq_seed_from_path(mnemonic, path, seed_len)` — the sibling child is
|
||||
derived by incrementing the last path level, so callers pass a single path.
|
||||
- PQ private keys are stored in seed form (ML-DSA-65 32 B, ML-KEM-768 64 B,
|
||||
SLH-DSA-128s 64 B sk serialization); `CryptoAlg::sizes()` reflects this.
|
||||
- SLH-DSA-128s uses the SHA2 parameter set (`slh_dsa::Sha2_128s`), matching
|
||||
the web app's `slh_dsa_sha2_128s`.
|
||||
- The v2 conformance test (`tests/pq_conformance.rs`) reproduces
|
||||
`seed-to-pubkeys.v2.json` exactly for all three algorithms.
|
||||
- The dispatcher's `encapsulate`/`decapsulate` verbs and the sign-verb
|
||||
private-key truncation bug were fixed as part of this work.
|
||||
- n_signer (C) still needs the same migration — filed separately there.
|
||||
|
||||
## Background
|
||||
|
||||
signer is the Rust port of n_signer and inherited two PQ derivation choices:
|
||||
|
||||
1. **SHAKE-256 DRBG pipeline** ([`src/pq_drbg.rs`](../src/pq_drbg.rs)): the
|
||||
BIP-44-derived 32-byte seed feeds a SHAKE-256 DRBG that stands in for
|
||||
PQClean's `randombytes()` callback during keygen. This was an API artifact
|
||||
of the C PQClean integration, not a cryptographic choice.
|
||||
2. **SLIP-0010 derivation for PQ paths**
|
||||
([`src/pq_crypto.rs:90`](../src/pq_crypto.rs)): `derive_seed_from_mnemonic()`
|
||||
branches on the path prefix — BIP-32 for `m/44'/1237'`, SLIP-0010 for
|
||||
everything else (ed25519, x25519, PQ).
|
||||
|
||||
The nostr_quantum_preparation web app (the project with actual users) has
|
||||
standardized v2 on:
|
||||
|
||||
- **Per-algorithm coin types** in the unregistered SLIP-44 `102XXX'` range
|
||||
(102003' ML-DSA-65, 102004' SLH-DSA-128s, 102005' ML-KEM-768 — matching
|
||||
signer's existing allocations — plus new 102006' ML-DSA-44 and 102007'
|
||||
Falcon-512).
|
||||
- **FIPS seeded keygen**: BIP32 child bytes (exact length: 32 B ML-DSA,
|
||||
48 B SLH-DSA-128s, 64 B ML-KEM) → `keygen(seed)`. No DRBG.
|
||||
- **BIP32 (not SLIP-0010)** for the PQ paths, via `@scure/bip32` `HDKey`.
|
||||
|
||||
## Problem
|
||||
|
||||
Two divergences prevent cross-project key parity (same mnemonic + same path →
|
||||
same PQ keys):
|
||||
|
||||
| Divergence | signer today | nostr_quantum_preparation v2 |
|
||||
|---|---|---|
|
||||
| Seed expansion | SHAKE-256 DRBG → RNG-fed keygen | exact-length seed → seeded keygen |
|
||||
| Derivation function for PQ paths | SLIP-0010 | BIP32 |
|
||||
|
||||
SLIP-0010 and BIP32 produce different master keys (different HMAC keys) and
|
||||
different child derivation, so even identical paths yield unrelated seeds.
|
||||
The DRBG pipeline additionally means signer can never reproduce FIPS-seeded
|
||||
keys regardless of derivation function.
|
||||
|
||||
Neither divergence is a security weakness — both are deterministic expansions
|
||||
of secret material — but parity matters operationally: a user should be able
|
||||
to derive the same PQ identity in the web app and on signer hardware from one
|
||||
mnemonic.
|
||||
|
||||
## Solution
|
||||
|
||||
Migrate signer's PQ keygen to the FIPS seeded interface and PQ path derivation
|
||||
to BIP32, keeping ed25519/x25519 on SLIP-0010 (correct for those curves).
|
||||
|
||||
### Target pipeline
|
||||
|
||||
```
|
||||
mnemonic → BIP39 seed → BIP32 master → m/44'/<coin>'/0'/0'/<n>' → child bytes
|
||||
→ concatenate/truncate to algorithm seed length → seeded keygen
|
||||
```
|
||||
|
||||
| Algorithm | Coin type | Path | Seed len | RustCrypto API |
|
||||
|---|---|---|---|---|
|
||||
| ML-DSA-65 | `102003'` | `m/44'/102003'/0'/0'/0'` | 32 B | `ml_dsa::SigningKey::from_seed(&[u8; 32])` |
|
||||
| SLH-DSA-128s | `102004'` | `m/44'/102004'/0'/0'/0'` + `/1'` | 48 B | `slh_dsa` seeded keygen (verify exact API at rc version in use) |
|
||||
| ML-KEM-768 | `102005'` | `m/44'/102005'/0'/0'/0'` + `/1'` | 64 B | `ml_kem::DecapsulationKey::from_seed(&[u8; 64])` |
|
||||
| ML-DSA-44 | `102006'` | `m/44'/102006'/0'/0'/0'` | 32 B | future — add with `ml-dsa` crate |
|
||||
| Falcon-512 | `102007'` | `m/44'/102007'/0'/0'/0'` + `/1'` | 48 B | future — no stable RustCrypto crate; rejection sampling makes cross-library determinism impossible anyway |
|
||||
|
||||
48/64-byte seeds come from two hardened children concatenated (first 48 of 64
|
||||
used where 48 B is required) — matching the web app's construction exactly.
|
||||
|
||||
### Falcon caveat
|
||||
|
||||
Falcon keygen is rejection-sampling-based with no universally implemented seed
|
||||
interface. Even with identical seeds, implementations disagree. The web app
|
||||
pins noble's behavior in test vectors and flags Falcon as per-library in its
|
||||
NIP proposal; signer should do the same when Falcon support lands, and should
|
||||
not promise parity for it.
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. `src/pq_crypto.rs`
|
||||
|
||||
- `derive_seed_from_mnemonic()`: route PQ coin types (102003'–102007') through
|
||||
BIP-32 (same branch as `m/44'/1237'`), keeping SLIP-0010 only for
|
||||
ed25519/x25519 (102001'/102002').
|
||||
- Add `derive_pq_seed(mnemonic, coin_type, indices) -> Vec<u8>` implementing
|
||||
the concatenate/truncate-to-length construction.
|
||||
- Replace DRBG-fed keygen call sites with the seeded APIs above.
|
||||
|
||||
### 2. `src/pq_drbg.rs`
|
||||
|
||||
- Keep the module (it is a faithful port and may serve future PQClean-style
|
||||
integrations) but remove it from the PQ keygen path. Mark as not-used-for-
|
||||
derivation in the module doc.
|
||||
|
||||
### 3. `src/alg_cache.rs`, `src/role_table.rs`, `src/tui.rs`, `src/main.rs`
|
||||
|
||||
- No path changes needed for 102003'–102005' (already correct).
|
||||
- Add `MlDsa44` (`102006'`) to `CryptoAlg`, path formatting, purpose mapping
|
||||
(`PqSig`), and TUI presets when the `ml-dsa` crate's ML-DSA-44 variant is
|
||||
wired in. Falcon (`102007'`) waits on a viable crate.
|
||||
|
||||
### 4. Tests
|
||||
|
||||
- Cross-implementation conformance: reproduce the web app's
|
||||
`seed-to-pubkeys.v2.json` vector (same fixed mnemonic) for ML-DSA-65,
|
||||
SLH-DSA-128s, and ML-KEM-768. This is the acceptance test for parity.
|
||||
- Regression: DRBG removal does not change ed25519/x25519 derivation.
|
||||
- Unit: 48/64-byte seed construction matches the two-children concatenation.
|
||||
|
||||
### 5. Docs
|
||||
|
||||
- `README.md` / `documents/` equivalent: document the seeded pipeline, the
|
||||
BIP32-for-PQ decision, the coin-type registry (102003'–102005' existing,
|
||||
102006'–102007' reserved), and the Falcon caveat.
|
||||
- Note for n_signer (C): same migration applies; file it there separately.
|
||||
|
||||
## What we are explicitly NOT doing
|
||||
|
||||
- Not changing secp256k1 NIP-06 derivation (`m/44'/1237'/n'/0/0`, BIP-32).
|
||||
- Not changing ed25519/x25519 SLIP-0010 derivation (correct for those curves).
|
||||
- Not preserving DRBG-derived PQ keys (no users; clean break is the point).
|
||||
- Not implementing Falcon now (no stable crate; determinism caveat).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. `cargo test` passes with the seeded pipeline.
|
||||
2. The web app's v2 vector reproduces exactly for ML-DSA-65, SLH-DSA-128s,
|
||||
ML-KEM-768 (same mnemonic → same pubkeys in Rust and JS).
|
||||
3. ed25519/x25519 pubkeys unchanged from pre-migration for the same mnemonic.
|
||||
+66
-6
@@ -103,7 +103,21 @@ fn derive_alg_key(
|
||||
.sizes()
|
||||
.ok_or(SignerError::KeyDerivationFailed)?;
|
||||
|
||||
let seed = crate::pq_crypto::derive_seed_from_mnemonic(mnemonic_phrase, path)?;
|
||||
// PQ algorithms use the v2 seeded derivation: exact-length seed
|
||||
// (32/48/64 B) from BIP-32 children, fed to the seeded keygen APIs.
|
||||
// Classical algorithms use the plain 32-byte derived seed.
|
||||
let seed = match alg {
|
||||
CryptoAlg::MlDsa65 => {
|
||||
crate::pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 32)?
|
||||
}
|
||||
CryptoAlg::SlhDsa128s => {
|
||||
crate::pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 48)?
|
||||
}
|
||||
CryptoAlg::MlKem768 => {
|
||||
crate::pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 64)?
|
||||
}
|
||||
_ => crate::pq_crypto::derive_seed_from_mnemonic(mnemonic_phrase, path)?.to_vec(),
|
||||
};
|
||||
|
||||
match alg {
|
||||
CryptoAlg::Secp256k1 => {
|
||||
@@ -149,7 +163,9 @@ fn derive_alg_key(
|
||||
})
|
||||
}
|
||||
CryptoAlg::Ed25519 => {
|
||||
let (priv_bytes, pub_bytes) = crate::pq_crypto::ed25519_keygen_from_seed(&seed);
|
||||
let seed_arr: [u8; 32] =
|
||||
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
|
||||
let (priv_bytes, pub_bytes) = crate::pq_crypto::ed25519_keygen_from_seed(&seed_arr);
|
||||
let pubkey_hex = hex::encode(&pub_bytes);
|
||||
let key_id = if pubkey_hex.len() >= 16 {
|
||||
pubkey_hex[..16].to_string()
|
||||
@@ -174,7 +190,9 @@ fn derive_alg_key(
|
||||
})
|
||||
}
|
||||
CryptoAlg::X25519 => {
|
||||
let (priv_bytes, pub_bytes) = crate::pq_crypto::x25519_keygen_from_seed(&seed);
|
||||
let seed_arr: [u8; 32] =
|
||||
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
|
||||
let (priv_bytes, pub_bytes) = crate::pq_crypto::x25519_keygen_from_seed(&seed_arr);
|
||||
let pubkey_hex = hex::encode(&pub_bytes);
|
||||
let key_id = if pubkey_hex.len() >= 16 {
|
||||
pubkey_hex[..16].to_string()
|
||||
@@ -198,10 +216,52 @@ fn derive_alg_key(
|
||||
valid: true,
|
||||
})
|
||||
}
|
||||
CryptoAlg::MlDsa65 | CryptoAlg::SlhDsa128s | CryptoAlg::MlKem768 => {
|
||||
// PQ algorithms — TODO: Phase 13
|
||||
Err(SignerError::NotYetImplemented)
|
||||
CryptoAlg::MlDsa65 => {
|
||||
let seed_arr: [u8; 32] =
|
||||
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
|
||||
let (priv_bytes, pub_bytes) = crate::pq_crypto::ml_dsa_65_keygen_from_seed(&seed_arr)?;
|
||||
finish_pq_entry(alg, index, sizes, priv_bytes, pub_bytes)
|
||||
}
|
||||
CryptoAlg::SlhDsa128s => {
|
||||
let (priv_bytes, pub_bytes) = crate::pq_crypto::slh_dsa_128s_keygen_from_seed(&seed)?;
|
||||
finish_pq_entry(alg, index, sizes, priv_bytes, pub_bytes)
|
||||
}
|
||||
CryptoAlg::MlKem768 => {
|
||||
let (priv_bytes, pub_bytes) = crate::pq_crypto::ml_kem_768_keygen_from_seed(&seed)?;
|
||||
finish_pq_entry(alg, index, sizes, priv_bytes, pub_bytes)
|
||||
}
|
||||
CryptoAlg::Unknown => Err(SignerError::InvalidInput),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an `AlgKeyEntry` from PQ keygen output (seed-form private key).
|
||||
fn finish_pq_entry(
|
||||
alg: CryptoAlg,
|
||||
index: i32,
|
||||
sizes: crate::pq_crypto::CryptoAlgSizes,
|
||||
priv_bytes: Vec<u8>,
|
||||
pub_bytes: Vec<u8>,
|
||||
) -> Result<AlgKeyEntry, SignerError> {
|
||||
let pubkey_hex = hex::encode(&pub_bytes);
|
||||
let key_id = if pubkey_hex.len() >= 16 {
|
||||
pubkey_hex[..16].to_string()
|
||||
} else {
|
||||
pubkey_hex.clone()
|
||||
};
|
||||
|
||||
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
|
||||
priv_buf.copy_from(&priv_bytes);
|
||||
|
||||
let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?;
|
||||
pub_buf.copy_from(&pub_bytes);
|
||||
|
||||
Ok(AlgKeyEntry {
|
||||
alg,
|
||||
index,
|
||||
private_key: priv_buf,
|
||||
public_key: pub_buf,
|
||||
pubkey_hex,
|
||||
key_id,
|
||||
valid: true,
|
||||
})
|
||||
}
|
||||
|
||||
+218
-13
@@ -179,7 +179,7 @@ fn handle_algorithm_verb(
|
||||
}
|
||||
|
||||
let priv_slice = key_entry.private_key.as_slice();
|
||||
let sig = sign_with_alg(alg, &priv_slice[..32].try_into().unwrap(), &msg_bytes);
|
||||
let sig = sign_with_alg(alg, priv_slice, &msg_bytes);
|
||||
match sig {
|
||||
Ok(s) => {
|
||||
let sig_hex = hex::encode(&s);
|
||||
@@ -286,13 +286,79 @@ fn handle_algorithm_verb(
|
||||
}
|
||||
|
||||
enforcement::VERB_ENCAPSULATE => {
|
||||
// ML-KEM-768 only — TODO: Phase 13
|
||||
make_error_response(id, RpcError::NOT_YET_IMPLEMENTED)
|
||||
// ML-KEM-768 only (enforced by enforce_verb_algorithm).
|
||||
// params[0] = peer public key hex (1184 bytes → 2368 hex chars).
|
||||
let pub_hex = match params.first().and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => return make_error_response(id, RpcError::INVALID_PARAMS),
|
||||
};
|
||||
let pub_bytes = match hex::decode(pub_hex) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return make_error_response(
|
||||
id,
|
||||
RpcError { code: -32602, message: "invalid_pubkey_hex" },
|
||||
),
|
||||
};
|
||||
let sizes = alg.sizes().unwrap();
|
||||
if pub_bytes.len() != sizes.pub_key_len {
|
||||
return make_error_response(
|
||||
id,
|
||||
RpcError { code: -32602, message: "invalid_pubkey_length" },
|
||||
);
|
||||
}
|
||||
|
||||
match crate::pq_crypto::ml_kem_768_encaps(&pub_bytes) {
|
||||
Ok((ct, ss)) => {
|
||||
let result = json!({
|
||||
"ciphertext": hex::encode(&ct),
|
||||
"shared_secret": hex::encode(&ss),
|
||||
"algorithm": "ml-kem-768",
|
||||
});
|
||||
make_success_response(id, &result.to_string())
|
||||
}
|
||||
Err(_) => make_error_response(
|
||||
id,
|
||||
RpcError { code: -32602, message: "encaps_failed" },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
enforcement::VERB_DECAPSULATE => {
|
||||
// ML-KEM-768 only — TODO: Phase 13
|
||||
make_error_response(id, RpcError::NOT_YET_IMPLEMENTED)
|
||||
// ML-KEM-768 only (enforced by enforce_verb_algorithm).
|
||||
// params[0] = ciphertext hex (1088 bytes → 2176 hex chars).
|
||||
let ct_hex = match params.first().and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => return make_error_response(id, RpcError::INVALID_PARAMS),
|
||||
};
|
||||
let ct_bytes = match hex::decode(ct_hex) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return make_error_response(
|
||||
id,
|
||||
RpcError { code: -32602, message: "invalid_ciphertext_hex" },
|
||||
),
|
||||
};
|
||||
let sizes = alg.sizes().unwrap();
|
||||
if ct_bytes.len() != sizes.ciphertext_len {
|
||||
return make_error_response(
|
||||
id,
|
||||
RpcError { code: -32602, message: "invalid_ciphertext_length" },
|
||||
);
|
||||
}
|
||||
|
||||
let priv_slice = key_entry.private_key.as_slice();
|
||||
match crate::pq_crypto::ml_kem_768_decaps(priv_slice, &ct_bytes) {
|
||||
Ok(ss) => {
|
||||
let result = json!({
|
||||
"shared_secret": hex::encode(&ss),
|
||||
"algorithm": "ml-kem-768",
|
||||
});
|
||||
make_success_response(id, &result.to_string())
|
||||
}
|
||||
Err(_) => make_error_response(
|
||||
id,
|
||||
RpcError { code: -32602, message: "decaps_failed" },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
_ => make_error_response(id, RpcError::METHOD_NOT_FOUND),
|
||||
@@ -563,18 +629,26 @@ fn is_nostr_verb(verb: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn sign_with_alg(alg: CryptoAlg, priv_key: &[u8; 32], msg: &[u8]) -> Result<Vec<u8>, SignerError> {
|
||||
fn sign_with_alg(alg: CryptoAlg, priv_key: &[u8], msg: &[u8]) -> Result<Vec<u8>, SignerError> {
|
||||
match alg {
|
||||
CryptoAlg::Secp256k1 => {
|
||||
if priv_key.len() != 32 {
|
||||
return Err(SignerError::CryptoFailed);
|
||||
}
|
||||
let arr: [u8; 32] = priv_key.try_into().unwrap();
|
||||
// Check for scheme option (schnorr default, ecdsa alternative)
|
||||
// For now, default to schnorr
|
||||
let sk = nostr_core::types::SecretKey::from_bytes(*priv_key);
|
||||
let sk = nostr_core::types::SecretKey::from_bytes(arr);
|
||||
let digest = nostr_core::crypto::sha256::sha256(msg);
|
||||
let sig = nostr_core::crypto::keys::schnorr_sign(&sk, &digest)?;
|
||||
Ok(sig.as_bytes().to_vec())
|
||||
}
|
||||
CryptoAlg::Ed25519 => {
|
||||
let sig = crate::pq_crypto::ed25519_sign(priv_key, msg);
|
||||
if priv_key.len() != 32 {
|
||||
return Err(SignerError::CryptoFailed);
|
||||
}
|
||||
let arr: [u8; 32] = priv_key.try_into().unwrap();
|
||||
let sig = crate::pq_crypto::ed25519_sign(&arr, msg);
|
||||
Ok(sig.to_vec())
|
||||
}
|
||||
CryptoAlg::MlDsa65 => crate::pq_crypto::ml_dsa_65_sign(priv_key, msg),
|
||||
@@ -738,11 +812,6 @@ mod tests {
|
||||
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
|
||||
|
||||
let msg_hex = hex::encode(b"hello world");
|
||||
let sign_req = format!(
|
||||
r#"{{"id":"5","method":"sign","params":["{}"],{{"algorithm":"ed25519","index":0}}}}"#,
|
||||
msg_hex
|
||||
);
|
||||
// Fix JSON format
|
||||
let sign_req = format!(
|
||||
r#"{{"id":"5","method":"sign","params":["{}",{{"algorithm":"ed25519","index":0}}]}}"#,
|
||||
msg_hex
|
||||
@@ -773,4 +842,140 @@ mod tests {
|
||||
let resp = handle_request(&mut ctx, req);
|
||||
assert!(resp.contains("\"error\""));
|
||||
}
|
||||
|
||||
// ── PQ algorithm verbs (v2 seeded derivation) ────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_ml_dsa_65_sign_verify() {
|
||||
let (mut table, mnemonic, mut store, mut cache) = setup();
|
||||
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
|
||||
|
||||
// get_public_key must succeed (was key_derivation_failed before).
|
||||
let req = r#"{"id":"p1","method":"get_public_key","params":[{"algorithm":"ml-dsa-65","index":0}]}"#;
|
||||
let resp = handle_request(&mut ctx, req);
|
||||
assert!(resp.contains("\"result\""), "get_public_key: {}", resp);
|
||||
let resp_json: Value = serde_json::from_str(&resp).unwrap();
|
||||
let pub_hex = resp_json["result"]["public_key"].as_str().unwrap();
|
||||
assert_eq!(pub_hex.len(), 1952 * 2);
|
||||
|
||||
// sign
|
||||
let msg_hex = hex::encode(b"hello world");
|
||||
let sign_req = format!(
|
||||
r#"{{"id":"p2","method":"sign","params":["{}",{{"algorithm":"ml-dsa-65","index":0}}]}}"#,
|
||||
msg_hex
|
||||
);
|
||||
let resp = handle_request(&mut ctx, &sign_req);
|
||||
assert!(resp.contains("\"result\""), "sign: {}", resp);
|
||||
let resp_json: Value = serde_json::from_str(&resp).unwrap();
|
||||
let sig_hex = resp_json["result"]["signature"].as_str().unwrap();
|
||||
assert_eq!(sig_hex.len(), 3309 * 2);
|
||||
|
||||
// verify (valid)
|
||||
let verify_req = format!(
|
||||
r#"{{"id":"p3","method":"verify","params":["{}","{}",{{"algorithm":"ml-dsa-65","index":0}}]}}"#,
|
||||
msg_hex, sig_hex
|
||||
);
|
||||
let resp = handle_request(&mut ctx, &verify_req);
|
||||
assert!(resp.contains("\"valid\":true"), "verify: {}", resp);
|
||||
|
||||
// verify (wrong message)
|
||||
let verify_req = format!(
|
||||
r#"{{"id":"p4","method":"verify","params":["{}","{}",{{"algorithm":"ml-dsa-65","index":0}}]}}"#,
|
||||
hex::encode(b"wrong message"),
|
||||
sig_hex
|
||||
);
|
||||
let resp = handle_request(&mut ctx, &verify_req);
|
||||
assert!(resp.contains("\"valid\":false"), "verify wrong: {}", resp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slh_dsa_128s_sign_verify() {
|
||||
let (mut table, mnemonic, mut store, mut cache) = setup();
|
||||
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
|
||||
|
||||
let req = r#"{"id":"s1","method":"get_public_key","params":[{"algorithm":"slh-dsa-128s","index":0}]}"#;
|
||||
let resp = handle_request(&mut ctx, req);
|
||||
assert!(resp.contains("\"result\""), "get_public_key: {}", resp);
|
||||
let resp_json: Value = serde_json::from_str(&resp).unwrap();
|
||||
let pub_hex = resp_json["result"]["public_key"].as_str().unwrap();
|
||||
assert_eq!(pub_hex.len(), 32 * 2);
|
||||
|
||||
let msg_hex = hex::encode(b"hello world");
|
||||
let sign_req = format!(
|
||||
r#"{{"id":"s2","method":"sign","params":["{}",{{"algorithm":"slh-dsa-128s","index":0}}]}}"#,
|
||||
msg_hex
|
||||
);
|
||||
let resp = handle_request(&mut ctx, &sign_req);
|
||||
assert!(resp.contains("\"result\""), "sign: {}", resp);
|
||||
let resp_json: Value = serde_json::from_str(&resp).unwrap();
|
||||
let sig_hex = resp_json["result"]["signature"].as_str().unwrap();
|
||||
assert_eq!(sig_hex.len(), 7856 * 2);
|
||||
|
||||
let verify_req = format!(
|
||||
r#"{{"id":"s3","method":"verify","params":["{}","{}",{{"algorithm":"slh-dsa-128s","index":0}}]}}"#,
|
||||
msg_hex, sig_hex
|
||||
);
|
||||
let resp = handle_request(&mut ctx, &verify_req);
|
||||
assert!(resp.contains("\"valid\":true"), "verify: {}", resp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ml_kem_768_encaps_decaps() {
|
||||
let (mut table, mnemonic, mut store, mut cache) = setup();
|
||||
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
|
||||
|
||||
// Derive our ML-KEM keypair to get a public key to encapsulate to.
|
||||
let req = r#"{"id":"k1","method":"get_public_key","params":[{"algorithm":"ml-kem-768","index":0}]}"#;
|
||||
let resp = handle_request(&mut ctx, req);
|
||||
assert!(resp.contains("\"result\""), "get_public_key: {}", resp);
|
||||
let resp_json: Value = serde_json::from_str(&resp).unwrap();
|
||||
let pub_hex = resp_json["result"]["public_key"].as_str().unwrap();
|
||||
assert_eq!(pub_hex.len(), 1184 * 2);
|
||||
|
||||
// encapsulate
|
||||
let enc_req = format!(
|
||||
r#"{{"id":"k2","method":"encapsulate","params":["{}",{{"algorithm":"ml-kem-768","index":0}}]}}"#,
|
||||
pub_hex
|
||||
);
|
||||
let resp = handle_request(&mut ctx, &enc_req);
|
||||
assert!(resp.contains("\"result\""), "encapsulate: {}", resp);
|
||||
let resp_json: Value = serde_json::from_str(&resp).unwrap();
|
||||
let ct_hex = resp_json["result"]["ciphertext"].as_str().unwrap();
|
||||
let ss_hex = resp_json["result"]["shared_secret"].as_str().unwrap();
|
||||
assert_eq!(ct_hex.len(), 1088 * 2);
|
||||
assert_eq!(ss_hex.len(), 32 * 2);
|
||||
|
||||
// decapsulate
|
||||
let dec_req = format!(
|
||||
r#"{{"id":"k3","method":"decapsulate","params":["{}",{{"algorithm":"ml-kem-768","index":0}}]}}"#,
|
||||
ct_hex
|
||||
);
|
||||
let resp = handle_request(&mut ctx, &dec_req);
|
||||
assert!(resp.contains("\"result\""), "decapsulate: {}", resp);
|
||||
let resp_json: Value = serde_json::from_str(&resp).unwrap();
|
||||
let ss2_hex = resp_json["result"]["shared_secret"].as_str().unwrap();
|
||||
assert_eq!(ss_hex, ss2_hex, "shared secrets must match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ml_kem_768_encaps_bad_pubkey_length() {
|
||||
let (mut table, mnemonic, mut store, mut cache) = setup();
|
||||
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
|
||||
|
||||
let req = r#"{"id":"k4","method":"encapsulate","params":["00ff",{"algorithm":"ml-kem-768","index":0}]}"#;
|
||||
let resp = handle_request(&mut ctx, req);
|
||||
assert!(resp.contains("invalid_pubkey_length"), "resp: {}", resp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pq_key_derivation_deterministic() {
|
||||
// Same mnemonic + index → same pubkey across dispatcher calls.
|
||||
let (mut table, mnemonic, mut store, mut cache) = setup();
|
||||
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
|
||||
|
||||
let req = r#"{"id":"d1","method":"get_public_key","params":[{"algorithm":"ml-dsa-65","index":0}]}"#;
|
||||
let resp1 = handle_request(&mut ctx, req);
|
||||
let resp2 = handle_request(&mut ctx, req);
|
||||
assert_eq!(resp1, resp2);
|
||||
}
|
||||
}
|
||||
|
||||
+76
-7
@@ -303,8 +303,15 @@ fn derive_for_role(
|
||||
.sizes()
|
||||
.ok_or(SignerError::KeyDerivationFailed)?;
|
||||
|
||||
// Derive the 32-byte seed from the mnemonic using the path
|
||||
let seed = pq_crypto::derive_seed_from_mnemonic(mnemonic_phrase, path)?;
|
||||
// PQ algorithms use the v2 seeded derivation: exact-length seed
|
||||
// (32/48/64 B) from BIP-32 children, fed to the seeded keygen APIs.
|
||||
// Classical algorithms use the plain 32-byte derived seed.
|
||||
let seed = match alg {
|
||||
CryptoAlg::MlDsa65 => pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 32)?,
|
||||
CryptoAlg::SlhDsa128s => pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 48)?,
|
||||
CryptoAlg::MlKem768 => pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 64)?,
|
||||
_ => pq_crypto::derive_seed_from_mnemonic(mnemonic_phrase, path)?.to_vec(),
|
||||
};
|
||||
|
||||
match alg {
|
||||
CryptoAlg::Secp256k1 => {
|
||||
@@ -345,7 +352,9 @@ fn derive_for_role(
|
||||
})
|
||||
}
|
||||
CryptoAlg::Ed25519 => {
|
||||
let (priv_bytes, pub_bytes) = pq_crypto::ed25519_keygen_from_seed(&seed);
|
||||
let seed_arr: [u8; 32] =
|
||||
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
|
||||
let (priv_bytes, pub_bytes) = pq_crypto::ed25519_keygen_from_seed(&seed_arr);
|
||||
|
||||
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
|
||||
priv_buf.copy_from(&priv_bytes);
|
||||
@@ -365,7 +374,9 @@ fn derive_for_role(
|
||||
})
|
||||
}
|
||||
CryptoAlg::X25519 => {
|
||||
let (priv_bytes, pub_bytes) = pq_crypto::x25519_keygen_from_seed(&seed);
|
||||
let seed_arr: [u8; 32] =
|
||||
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
|
||||
let (priv_bytes, pub_bytes) = pq_crypto::x25519_keygen_from_seed(&seed_arr);
|
||||
|
||||
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
|
||||
priv_buf.copy_from(&priv_bytes);
|
||||
@@ -384,9 +395,67 @@ fn derive_for_role(
|
||||
valid: true,
|
||||
})
|
||||
}
|
||||
CryptoAlg::MlDsa65 | CryptoAlg::SlhDsa128s | CryptoAlg::MlKem768 => {
|
||||
// PQ algorithms — TODO: Phase 13
|
||||
Err(SignerError::NotYetImplemented)
|
||||
CryptoAlg::MlDsa65 => {
|
||||
let seed_arr: [u8; 32] =
|
||||
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
|
||||
let (priv_bytes, pub_bytes) = pq_crypto::ml_dsa_65_keygen_from_seed(&seed_arr)?;
|
||||
|
||||
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
|
||||
priv_buf.copy_from(&priv_bytes);
|
||||
|
||||
let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?;
|
||||
pub_buf.copy_from(&pub_bytes);
|
||||
|
||||
let pubkey_hex = hex::encode(&pub_bytes);
|
||||
|
||||
Ok(DerivedKey {
|
||||
private_key: priv_buf,
|
||||
public_key: pub_buf,
|
||||
pubkey_hex,
|
||||
npub: String::new(),
|
||||
alg,
|
||||
valid: true,
|
||||
})
|
||||
}
|
||||
CryptoAlg::SlhDsa128s => {
|
||||
let (priv_bytes, pub_bytes) = pq_crypto::slh_dsa_128s_keygen_from_seed(&seed)?;
|
||||
|
||||
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
|
||||
priv_buf.copy_from(&priv_bytes);
|
||||
|
||||
let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?;
|
||||
pub_buf.copy_from(&pub_bytes);
|
||||
|
||||
let pubkey_hex = hex::encode(&pub_bytes);
|
||||
|
||||
Ok(DerivedKey {
|
||||
private_key: priv_buf,
|
||||
public_key: pub_buf,
|
||||
pubkey_hex,
|
||||
npub: String::new(),
|
||||
alg,
|
||||
valid: true,
|
||||
})
|
||||
}
|
||||
CryptoAlg::MlKem768 => {
|
||||
let (priv_bytes, pub_bytes) = pq_crypto::ml_kem_768_keygen_from_seed(&seed)?;
|
||||
|
||||
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
|
||||
priv_buf.copy_from(&priv_bytes);
|
||||
|
||||
let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?;
|
||||
pub_buf.copy_from(&pub_bytes);
|
||||
|
||||
let pubkey_hex = hex::encode(&pub_bytes);
|
||||
|
||||
Ok(DerivedKey {
|
||||
private_key: priv_buf,
|
||||
public_key: pub_buf,
|
||||
pubkey_hex,
|
||||
npub: String::new(),
|
||||
alg,
|
||||
valid: true,
|
||||
})
|
||||
}
|
||||
CryptoAlg::Unknown => Err(SignerError::KeyDerivationFailed),
|
||||
}
|
||||
|
||||
+1
-1
@@ -31,4 +31,4 @@ pub mod error;
|
||||
pub use error::SignerError;
|
||||
|
||||
/// Version string (matches C NSIGNER_VERSION).
|
||||
pub const VERSION: &str = "v0.0.18";
|
||||
pub const VERSION: &str = "v0.0.19";
|
||||
|
||||
+383
-60
@@ -1,8 +1,13 @@
|
||||
//! Post-quantum crypto algorithm registry.
|
||||
//! Post-quantum crypto algorithm registry and operations.
|
||||
//!
|
||||
//! Port of `pq_crypto.c`. Provides the `CryptoAlg` enum and size
|
||||
//! constants for all six algorithms. Actual crypto operations
|
||||
//! (ed25519, x25519, PQ) are implemented in Phase 13.
|
||||
//! Port of `pq_crypto.c`. Provides the `CryptoAlg` enum, size constants,
|
||||
//! and crypto operations for all six algorithms.
|
||||
//!
|
||||
//! PQ keygen uses the v2 FIPS seeded derivation scheme (see
|
||||
//! `plans/pq_seeded_derivation_plan.md`): BIP-32 child bytes at the exact
|
||||
//! seed length required by each algorithm feed the seeded keygen APIs
|
||||
//! directly — no DRBG expansion. This matches the nostr_quantum_preparation
|
||||
//! web app byte-for-byte (same mnemonic + path → same pubkeys).
|
||||
|
||||
// ── Algorithm Identifiers ────────────────────────────────────────────────────
|
||||
|
||||
@@ -55,6 +60,13 @@ pub struct CryptoAlgSizes {
|
||||
}
|
||||
|
||||
impl CryptoAlg {
|
||||
/// Sizes for each algorithm.
|
||||
///
|
||||
/// PQ private keys are stored in **seed form** (the preferred
|
||||
/// serialization of the RustCrypto crates): ML-DSA-65 as the 32-byte ξ
|
||||
/// seed, ML-KEM-768 as the 64-byte d ∥ z seed, SLH-DSA-128s as the
|
||||
/// 64-byte sk serialization (sk.seed ∥ sk.prf ∥ pk). Public key,
|
||||
/// signature, and ciphertext lengths are the standard FIPS sizes.
|
||||
pub fn sizes(&self) -> Option<CryptoAlgSizes> {
|
||||
match self {
|
||||
Self::Secp256k1 => Some(CryptoAlgSizes {
|
||||
@@ -67,26 +79,47 @@ impl CryptoAlg {
|
||||
priv_key_len: 32, pub_key_len: 32, sig_len: 0, ciphertext_len: 0, shared_secret_len: 32,
|
||||
}),
|
||||
Self::MlDsa65 => Some(CryptoAlgSizes {
|
||||
priv_key_len: 4032, pub_key_len: 1952, sig_len: 3309, ciphertext_len: 0, shared_secret_len: 0,
|
||||
priv_key_len: 32, pub_key_len: 1952, sig_len: 3309, ciphertext_len: 0, shared_secret_len: 0,
|
||||
}),
|
||||
Self::SlhDsa128s => Some(CryptoAlgSizes {
|
||||
priv_key_len: 64, pub_key_len: 32, sig_len: 7856, ciphertext_len: 0, shared_secret_len: 0,
|
||||
}),
|
||||
Self::MlKem768 => Some(CryptoAlgSizes {
|
||||
priv_key_len: 2400, pub_key_len: 1184, sig_len: 0, ciphertext_len: 1088, shared_secret_len: 32,
|
||||
priv_key_len: 64, pub_key_len: 1184, sig_len: 0, ciphertext_len: 1088, shared_secret_len: 32,
|
||||
}),
|
||||
Self::Unknown => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Crypto Operations (stubs — Phase 13) ─────────────────────────────────────
|
||||
// ── Crypto Operations ────────────────────────────────────────────────────────
|
||||
|
||||
/// Derive a 32-byte seed from a mnemonic using a BIP-44 path (SLIP-0010).
|
||||
/// BIP-32 path prefixes routed through BIP-32 derivation.
|
||||
///
|
||||
/// For secp256k1: uses BIP-32 derivation.
|
||||
/// - `m/44'/1237'` — Nostr secp256k1 (NIP-06)
|
||||
/// - `m/44'/102003'` … `m/44'/102007'` — PQ coin types (v2 seeded scheme;
|
||||
/// 102003' ML-DSA-65, 102004' SLH-DSA-128s, 102005' ML-KEM-768,
|
||||
/// 102006' ML-DSA-44 and 102007' Falcon-512 reserved)
|
||||
///
|
||||
/// Everything else — ed25519 (`102001'`) and x25519 (`102002'`) — uses
|
||||
/// SLIP-0010, which is the correct derivation for those curves.
|
||||
const BIP32_PATH_PREFIXES: &[&str] = &[
|
||||
"m/44'/1237'",
|
||||
"m/44'/102003'",
|
||||
"m/44'/102004'",
|
||||
"m/44'/102005'",
|
||||
"m/44'/102006'",
|
||||
"m/44'/102007'",
|
||||
];
|
||||
|
||||
/// Derive a 32-byte seed from a mnemonic using a BIP-44 path.
|
||||
///
|
||||
/// For secp256k1 and PQ coin types: uses BIP-32 derivation.
|
||||
/// For ed25519/x25519: uses SLIP-0010 (all-hardened).
|
||||
/// For PQ: uses SLIP-0010 to get a 32-byte seed, then feeds DRBG for keygen.
|
||||
///
|
||||
/// Note: PQ keygen should use [`derive_pq_seed_from_path`] instead — it
|
||||
/// produces the exact-length seed (32/48/64 bytes) required by the FIPS
|
||||
/// seeded keygen APIs.
|
||||
pub fn derive_seed_from_mnemonic(
|
||||
mnemonic: &str,
|
||||
path: &str,
|
||||
@@ -97,16 +130,14 @@ pub fn derive_seed_from_mnemonic(
|
||||
let path_indices = nips::nip006::parse_bip44_path(path)
|
||||
.map_err(|_| crate::SignerError::KeyDerivationFailed)?;
|
||||
|
||||
// Determine if this is a secp256k1 path (BIP-32) or ed25519/x25519 path (SLIP-0010)
|
||||
// by checking the purpose prefix.
|
||||
if path.starts_with("m/44'/1237'") {
|
||||
// BIP-32 derivation for secp256k1
|
||||
// BIP-32 for secp256k1 (NIP-06) and PQ coin types (v2 seeded scheme);
|
||||
// SLIP-0010 for ed25519/x25519 (correct for those curves).
|
||||
if BIP32_PATH_PREFIXES.iter().any(|p| path.starts_with(p)) {
|
||||
let (master_key, master_chain_code) = nips::nip006::bip32_master_key(&seed);
|
||||
let (derived_key, _) = nips::nip006::bip32_derive_path(&master_key, &master_chain_code, &path_indices)
|
||||
.map_err(|_| crate::SignerError::KeyDerivationFailed)?;
|
||||
Ok(derived_key)
|
||||
} else {
|
||||
// SLIP-0010 derivation for ed25519/x25519/PQ
|
||||
let (master_key, master_chain_code) = nips::nip006::slip10_master_key(&seed);
|
||||
let (derived_key, _) = nips::nip006::slip10_derive_path(&master_key, &master_chain_code, &path_indices)
|
||||
.map_err(|_| crate::SignerError::KeyDerivationFailed)?;
|
||||
@@ -114,6 +145,60 @@ pub fn derive_seed_from_mnemonic(
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a PQ keygen seed of `seed_len` bytes (32/48/64) from a BIP-32 path.
|
||||
///
|
||||
/// v2 seeded construction (matches nostr_quantum_preparation exactly):
|
||||
/// - 32-byte seeds: the child private key at `path`.
|
||||
/// - 48/64-byte seeds: the children at `path` and at the sibling path (last
|
||||
/// level incremented by 1, hardened bit preserved) concatenated to 64
|
||||
/// bytes, then truncated to the FIRST `seed_len` bytes.
|
||||
///
|
||||
/// The truncation rule is normative: taking the last 48 bytes or
|
||||
/// concatenating in the opposite order produces different keys and breaks
|
||||
/// seed-phrase recoverability.
|
||||
pub fn derive_pq_seed_from_path(
|
||||
mnemonic: &str,
|
||||
path: &str,
|
||||
seed_len: usize,
|
||||
) -> Result<Vec<u8>, crate::SignerError> {
|
||||
if !matches!(seed_len, 32 | 48 | 64) {
|
||||
return Err(crate::SignerError::InvalidInput);
|
||||
}
|
||||
|
||||
let bip39_seed = nips::nip006::mnemonic_to_seed(mnemonic, "");
|
||||
let (master_key, master_chain_code) = nips::nip006::bip32_master_key(&bip39_seed);
|
||||
|
||||
let indices = nips::nip006::parse_bip44_path(path)
|
||||
.map_err(|_| crate::SignerError::KeyDerivationFailed)?;
|
||||
if indices.is_empty() {
|
||||
return Err(crate::SignerError::KeyDerivationFailed);
|
||||
}
|
||||
|
||||
let (child0, _) = nips::nip006::bip32_derive_path(&master_key, &master_chain_code, &indices)
|
||||
.map_err(|_| crate::SignerError::KeyDerivationFailed)?;
|
||||
|
||||
if seed_len == 32 {
|
||||
return Ok(child0.to_vec());
|
||||
}
|
||||
|
||||
// Sibling path: last level + 1 (a plain u32 increment preserves the
|
||||
// hardened bit: 0x80000000 + 1 = 0x80000001, i.e. hardened 1').
|
||||
let mut sibling_indices = indices.clone();
|
||||
let last = sibling_indices
|
||||
.last_mut()
|
||||
.ok_or(crate::SignerError::KeyDerivationFailed)?;
|
||||
*last = last.wrapping_add(1);
|
||||
|
||||
let (child1, _) =
|
||||
nips::nip006::bip32_derive_path(&master_key, &master_chain_code, &sibling_indices)
|
||||
.map_err(|_| crate::SignerError::KeyDerivationFailed)?;
|
||||
|
||||
let mut combined = [0u8; 64];
|
||||
combined[..32].copy_from_slice(&child0);
|
||||
combined[32..].copy_from_slice(&child1);
|
||||
Ok(combined[..seed_len].to_vec())
|
||||
}
|
||||
|
||||
/// ed25519: derive keypair from a 32-byte seed.
|
||||
pub fn ed25519_keygen_from_seed(seed: &[u8; 32]) -> ([u8; 32], [u8; 32]) {
|
||||
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||
@@ -200,69 +285,146 @@ pub fn secp256k1_ecdsa_verify(pub_key: &[u8; 32], msg: &[u8], sig: &[u8; 64]) ->
|
||||
secp.verify_ecdsa(&msg, &signature, &pk).is_ok()
|
||||
}
|
||||
|
||||
// ── PQ Crypto Stubs ──────────────────────────────────────────────────────────
|
||||
// ── PQ Crypto (FIPS seeded keygen — v2 scheme) ───────────────────────────────
|
||||
//
|
||||
// The pure Rust crates (ml-dsa, ml-kem, slh-dsa) are included as dependencies
|
||||
// for future implementation. Their APIs use `TryCryptoRng`, `KeyExport`, and
|
||||
// other traits that require careful integration with the SHAKE-256 DRBG.
|
||||
//
|
||||
// TODO: Wire up the crate APIs for deterministic keygen from seed, sign, verify,
|
||||
// encapsulate, and decapsulate operations.
|
||||
// Keygen consumes the exact-length seed derived by `derive_pq_seed_from_path`
|
||||
// (32 B ML-DSA, 48 B SLH-DSA, 64 B ML-KEM) via the RustCrypto seeded APIs.
|
||||
// Private keys are stored in seed form (see `CryptoAlg::sizes`).
|
||||
|
||||
/// ML-DSA-65: generate keypair from a 32-byte seed (deterministic).
|
||||
/// TODO: Wire up ml-dsa crate API.
|
||||
pub fn ml_dsa_65_keygen_from_seed(_seed: &[u8; 32]) -> Result<(Vec<u8>, Vec<u8>), crate::SignerError> {
|
||||
Err(crate::SignerError::NotYetImplemented)
|
||||
///
|
||||
/// Returns (private_key = 32-byte ξ seed, public_key = 1952 bytes).
|
||||
pub fn ml_dsa_65_keygen_from_seed(seed: &[u8; 32]) -> Result<(Vec<u8>, Vec<u8>), crate::SignerError> {
|
||||
use ml_dsa::{MlDsa65, SigningKey, signature::Keypair};
|
||||
|
||||
let sk = SigningKey::<MlDsa65>::from_seed(seed.into());
|
||||
let vk = sk.verifying_key();
|
||||
Ok((sk.to_seed().to_vec(), vk.encode().to_vec()))
|
||||
}
|
||||
|
||||
/// ML-DSA-65: sign a message.
|
||||
/// TODO: Wire up ml-dsa crate API.
|
||||
pub fn ml_dsa_65_sign(_priv: &[u8], _msg: &[u8]) -> Result<Vec<u8>, crate::SignerError> {
|
||||
Err(crate::SignerError::NotYetImplemented)
|
||||
/// ML-DSA-65: sign a message. priv is the 32-byte ξ seed.
|
||||
/// Returns the 3309-byte signature (deterministic FIPS 204 variant).
|
||||
pub fn ml_dsa_65_sign(priv_key: &[u8], msg: &[u8]) -> Result<Vec<u8>, crate::SignerError> {
|
||||
use ml_dsa::{MlDsa65, Seed, SigningKey, signature::Signer};
|
||||
|
||||
if priv_key.len() != 32 {
|
||||
return Err(crate::SignerError::InvalidInput);
|
||||
}
|
||||
let seed: Seed = priv_key.try_into().map_err(|_| crate::SignerError::InvalidInput)?;
|
||||
let sk = SigningKey::<MlDsa65>::from_seed(&seed);
|
||||
let sig = sk.sign(msg);
|
||||
Ok(sig.encode().to_vec())
|
||||
}
|
||||
|
||||
/// ML-DSA-65: verify a signature.
|
||||
/// TODO: Wire up ml-dsa crate API.
|
||||
pub fn ml_dsa_65_verify(_pub: &[u8], _msg: &[u8], _sig: &[u8]) -> bool {
|
||||
false
|
||||
/// ML-DSA-65: verify a signature. pub is the 1952-byte public key.
|
||||
pub fn ml_dsa_65_verify(pub_key: &[u8], msg: &[u8], sig: &[u8]) -> bool {
|
||||
use ml_dsa::{MlDsa65, Signature, VerifyingKey, signature::Verifier};
|
||||
|
||||
if pub_key.len() != 1952 {
|
||||
return false;
|
||||
}
|
||||
let enc: ml_dsa::EncodedVerifyingKey<MlDsa65> =
|
||||
match pub_key.try_into() {
|
||||
Ok(e) => e,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let vk = VerifyingKey::<MlDsa65>::decode(&enc);
|
||||
let signature = match Signature::<MlDsa65>::try_from(sig) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
vk.verify(msg, &signature).is_ok()
|
||||
}
|
||||
|
||||
/// SLH-DSA-128s: generate keypair from a 32-byte seed (deterministic).
|
||||
/// TODO: Wire up slh-dsa crate API.
|
||||
pub fn slh_dsa_128s_keygen_from_seed(_seed: &[u8; 32]) -> Result<(Vec<u8>, Vec<u8>), crate::SignerError> {
|
||||
Err(crate::SignerError::NotYetImplemented)
|
||||
/// SLH-DSA-128s (SHA2 small): generate keypair from a 48-byte seed.
|
||||
///
|
||||
/// The seed splits as sk.seed(16) ∥ sk.prf(16) ∥ pk.seed(16) — matching
|
||||
/// noble's `slh_dsa_sha2_128s.keygen(seed)` exactly.
|
||||
/// Returns (private_key = 64-byte sk serialization, public_key = 32 bytes).
|
||||
pub fn slh_dsa_128s_keygen_from_seed(seed: &[u8]) -> Result<(Vec<u8>, Vec<u8>), crate::SignerError> {
|
||||
use slh_dsa::{Sha2_128s, SigningKey, signature::Keypair};
|
||||
|
||||
if seed.len() != 48 {
|
||||
return Err(crate::SignerError::InvalidInput);
|
||||
}
|
||||
let sk = SigningKey::<Sha2_128s>::slh_keygen_internal(&seed[..16], &seed[16..32], &seed[32..48]);
|
||||
let vk = sk.verifying_key();
|
||||
Ok((sk.to_bytes().to_vec(), vk.to_bytes().to_vec()))
|
||||
}
|
||||
|
||||
/// SLH-DSA-128s: sign a message.
|
||||
/// TODO: Wire up slh-dsa crate API.
|
||||
pub fn slh_dsa_128s_sign(_priv: &[u8], _msg: &[u8]) -> Result<Vec<u8>, crate::SignerError> {
|
||||
Err(crate::SignerError::NotYetImplemented)
|
||||
/// SLH-DSA-128s: sign a message. priv is the 64-byte sk serialization.
|
||||
/// Returns the 7856-byte signature (deterministic: opt_rand = pk.seed).
|
||||
pub fn slh_dsa_128s_sign(priv_key: &[u8], msg: &[u8]) -> Result<Vec<u8>, crate::SignerError> {
|
||||
use slh_dsa::{Sha2_128s, SigningKey, signature::Signer};
|
||||
|
||||
let sk = SigningKey::<Sha2_128s>::try_from(priv_key)
|
||||
.map_err(|_| crate::SignerError::InvalidInput)?;
|
||||
let sig = sk.sign(msg);
|
||||
Ok(sig.to_vec())
|
||||
}
|
||||
|
||||
/// SLH-DSA-128s: verify a signature.
|
||||
/// TODO: Wire up slh-dsa crate API.
|
||||
pub fn slh_dsa_128s_verify(_pub: &[u8], _msg: &[u8], _sig: &[u8]) -> bool {
|
||||
false
|
||||
/// SLH-DSA-128s: verify a signature. pub is the 32-byte public key.
|
||||
pub fn slh_dsa_128s_verify(pub_key: &[u8], msg: &[u8], sig: &[u8]) -> bool {
|
||||
use slh_dsa::{Sha2_128s, Signature, VerifyingKey, signature::Verifier};
|
||||
|
||||
let vk = match VerifyingKey::<Sha2_128s>::try_from(pub_key) {
|
||||
Ok(k) => k,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let signature = match Signature::<Sha2_128s>::try_from(sig) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
vk.verify(msg, &signature).is_ok()
|
||||
}
|
||||
|
||||
/// ML-KEM-768: generate keypair from a 32-byte seed (deterministic).
|
||||
/// TODO: Wire up ml-kem crate API.
|
||||
pub fn ml_kem_768_keygen_from_seed(_seed: &[u8; 32]) -> Result<(Vec<u8>, Vec<u8>), crate::SignerError> {
|
||||
Err(crate::SignerError::NotYetImplemented)
|
||||
/// ML-KEM-768: generate keypair from a 64-byte seed (deterministic).
|
||||
///
|
||||
/// The seed splits as d(32) ∥ z(32) — matching noble's `ml_kem768.keygen(seed)`.
|
||||
/// Returns (private_key = 64-byte seed, public_key = 1184 bytes).
|
||||
pub fn ml_kem_768_keygen_from_seed(seed: &[u8]) -> Result<(Vec<u8>, Vec<u8>), crate::SignerError> {
|
||||
use ml_kem::ml_kem_768::DecapsulationKey;
|
||||
use ml_kem::{KeyExport, Seed};
|
||||
|
||||
if seed.len() != 64 {
|
||||
return Err(crate::SignerError::InvalidInput);
|
||||
}
|
||||
let seed: Seed = seed.try_into().map_err(|_| crate::SignerError::InvalidInput)?;
|
||||
let dk = DecapsulationKey::from_seed(seed);
|
||||
let ek = dk.encapsulation_key();
|
||||
Ok((dk.to_seed().ok_or(crate::SignerError::CryptoFailed)?.to_vec(), ek.to_bytes().to_vec()))
|
||||
}
|
||||
|
||||
/// ML-KEM-768: encapsulate. pub is 1184-byte public key.
|
||||
/// Returns (ciphertext[1088], shared_secret[32]).
|
||||
/// TODO: Wire up ml-kem crate API.
|
||||
pub fn ml_kem_768_encaps(_pub: &[u8]) -> Result<(Vec<u8>, [u8; 32]), crate::SignerError> {
|
||||
Err(crate::SignerError::NotYetImplemented)
|
||||
/// ML-KEM-768: encapsulate. pub is the 1184-byte public key.
|
||||
/// Returns (ciphertext[1088], shared_secret[32]). Uses OS randomness —
|
||||
/// each encapsulation produces a different ciphertext, by design.
|
||||
pub fn ml_kem_768_encaps(pub_key: &[u8]) -> Result<(Vec<u8>, [u8; 32]), crate::SignerError> {
|
||||
use ml_kem::ml_kem_768::EncapsulationKey;
|
||||
use ml_kem::kem::Encapsulate;
|
||||
|
||||
let ek = EncapsulationKey::new(
|
||||
pub_key.try_into().map_err(|_| crate::SignerError::InvalidInput)?,
|
||||
)
|
||||
.map_err(|_| crate::SignerError::InvalidInput)?;
|
||||
let (ct, ss) = ek.encapsulate();
|
||||
Ok((ct.to_vec(), ss.into()))
|
||||
}
|
||||
|
||||
/// ML-KEM-768: decapsulate. priv is 2400-byte secret key, ct is 1088-byte ciphertext.
|
||||
/// Returns shared_secret[32].
|
||||
/// TODO: Wire up ml-kem crate API.
|
||||
pub fn ml_kem_768_decaps(_priv: &[u8], _ct: &[u8]) -> Result<[u8; 32], crate::SignerError> {
|
||||
Err(crate::SignerError::NotYetImplemented)
|
||||
/// ML-KEM-768: decapsulate. priv is the 64-byte seed, ct is the 1088-byte
|
||||
/// ciphertext. Returns shared_secret[32].
|
||||
pub fn ml_kem_768_decaps(priv_key: &[u8], ct: &[u8]) -> Result<[u8; 32], crate::SignerError> {
|
||||
use ml_kem::ml_kem_768::DecapsulationKey;
|
||||
use ml_kem::kem::Decapsulate;
|
||||
use ml_kem::Seed;
|
||||
|
||||
if priv_key.len() != 64 {
|
||||
return Err(crate::SignerError::InvalidInput);
|
||||
}
|
||||
let seed: Seed = priv_key.try_into().map_err(|_| crate::SignerError::InvalidInput)?;
|
||||
let dk = DecapsulationKey::from_seed(seed);
|
||||
let ct_arr = ct.try_into().map_err(|_| crate::SignerError::InvalidInput)?;
|
||||
let ss = dk.decapsulate(&ct_arr);
|
||||
Ok(ss.into())
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
@@ -276,6 +438,9 @@ fn sha256(data: &[u8]) -> [u8; 32] {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The fixed test mnemonic used by nostr_quantum_preparation's vectors.
|
||||
const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
||||
|
||||
#[test]
|
||||
fn test_alg_from_str() {
|
||||
assert_eq!(CryptoAlg::from_str("secp256k1"), CryptoAlg::Secp256k1);
|
||||
@@ -290,10 +455,22 @@ mod tests {
|
||||
assert_eq!(s.priv_key_len, 32);
|
||||
assert_eq!(s.pub_key_len, 32);
|
||||
|
||||
// PQ private keys are stored in seed form.
|
||||
let s = CryptoAlg::MlDsa65.sizes().unwrap();
|
||||
assert_eq!(s.priv_key_len, 32);
|
||||
assert_eq!(s.pub_key_len, 1952);
|
||||
assert_eq!(s.sig_len, 3309);
|
||||
|
||||
let s = CryptoAlg::SlhDsa128s.sizes().unwrap();
|
||||
assert_eq!(s.priv_key_len, 64);
|
||||
assert_eq!(s.pub_key_len, 32);
|
||||
assert_eq!(s.sig_len, 7856);
|
||||
|
||||
let s = CryptoAlg::MlKem768.sizes().unwrap();
|
||||
assert_eq!(s.priv_key_len, 2400);
|
||||
assert_eq!(s.priv_key_len, 64);
|
||||
assert_eq!(s.pub_key_len, 1184);
|
||||
assert_eq!(s.ciphertext_len, 1088);
|
||||
assert_eq!(s.shared_secret_len, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -316,4 +493,150 @@ mod tests {
|
||||
let shared_b = x25519_ecdh(&priv_b, &pub_a);
|
||||
assert_eq!(shared_a, shared_b);
|
||||
}
|
||||
|
||||
// ── v2 seeded derivation ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_pq_seed_lengths() {
|
||||
// 32-byte seed: single child.
|
||||
let s32 = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'", 32).unwrap();
|
||||
assert_eq!(s32.len(), 32);
|
||||
|
||||
// 48-byte seed: two children concatenated, first 48 of 64.
|
||||
let s48 = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102004'/0'/0'/0'", 48).unwrap();
|
||||
assert_eq!(s48.len(), 48);
|
||||
|
||||
// 64-byte seed: two children concatenated, all 64.
|
||||
let s64 = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102005'/0'/0'/0'", 64).unwrap();
|
||||
assert_eq!(s64.len(), 64);
|
||||
|
||||
// The 48-byte seed is a prefix of the 64-byte seed only when the
|
||||
// paths share the same coin type — here they differ, so just check
|
||||
// prefix consistency within the same coin type.
|
||||
let s48b = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102005'/0'/0'/0'", 48).unwrap();
|
||||
assert_eq!(&s64[..48], s48b.as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pq_seed_determinism() {
|
||||
let a = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'", 32).unwrap();
|
||||
let b = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'", 32).unwrap();
|
||||
assert_eq!(a, b);
|
||||
|
||||
// Different index → different seed.
|
||||
let c = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/1'", 32).unwrap();
|
||||
assert_ne!(a, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pq_seed_invalid_length() {
|
||||
assert!(derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'", 33).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pq_paths_use_bip32() {
|
||||
// PQ coin types must route through BIP-32 (v2 scheme), not SLIP-0010.
|
||||
// The 32-byte PQ seed equals the BIP-32 child at the same path.
|
||||
let pq_seed =
|
||||
derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'", 32).unwrap();
|
||||
let bip32_seed =
|
||||
derive_seed_from_mnemonic(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'").unwrap();
|
||||
assert_eq!(pq_seed, bip32_seed.to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ed25519_path_still_slip10() {
|
||||
// ed25519 (102001') must remain SLIP-0010 — regression guard.
|
||||
let seed = derive_seed_from_mnemonic(TEST_MNEMONIC, "m/44'/102001'/0'/0'/0'").unwrap();
|
||||
|
||||
let bip39_seed = nips::nip006::mnemonic_to_seed(TEST_MNEMONIC, "");
|
||||
let (master_key, master_chain_code) = nips::nip006::slip10_master_key(&bip39_seed);
|
||||
let path_indices = nips::nip006::parse_bip44_path("m/44'/102001'/0'/0'/0'").unwrap();
|
||||
let (expected, _) =
|
||||
nips::nip006::slip10_derive_path(&master_key, &master_chain_code, &path_indices)
|
||||
.unwrap();
|
||||
assert_eq!(seed, expected);
|
||||
}
|
||||
|
||||
// ── PQ keygen / sign / verify / KEM roundtrips ────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_ml_dsa_65_roundtrip() {
|
||||
let seed = [0x42u8; 32];
|
||||
let (priv_key, pub_key) = ml_dsa_65_keygen_from_seed(&seed).unwrap();
|
||||
assert_eq!(priv_key.len(), 32);
|
||||
assert_eq!(pub_key.len(), 1952);
|
||||
|
||||
// Determinism: same seed → same keypair.
|
||||
let (priv2, pub2) = ml_dsa_65_keygen_from_seed(&seed).unwrap();
|
||||
assert_eq!(priv_key, priv2);
|
||||
assert_eq!(pub_key, pub2);
|
||||
|
||||
let msg = b"hello world";
|
||||
let sig = ml_dsa_65_sign(&priv_key, msg).unwrap();
|
||||
assert_eq!(sig.len(), 3309);
|
||||
assert!(ml_dsa_65_verify(&pub_key, msg, &sig));
|
||||
assert!(!ml_dsa_65_verify(&pub_key, b"wrong message", &sig));
|
||||
|
||||
// Different seed → different key → verify fails.
|
||||
let (_, pub_other) = ml_dsa_65_keygen_from_seed(&[0x99u8; 32]).unwrap();
|
||||
assert!(!ml_dsa_65_verify(&pub_other, msg, &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slh_dsa_128s_roundtrip() {
|
||||
let seed = [0x42u8; 48];
|
||||
let (priv_key, pub_key) = slh_dsa_128s_keygen_from_seed(&seed).unwrap();
|
||||
assert_eq!(priv_key.len(), 64);
|
||||
assert_eq!(pub_key.len(), 32);
|
||||
|
||||
// Determinism.
|
||||
let (priv2, pub2) = slh_dsa_128s_keygen_from_seed(&seed).unwrap();
|
||||
assert_eq!(priv_key, priv2);
|
||||
assert_eq!(pub_key, pub2);
|
||||
|
||||
let msg = b"hello world";
|
||||
let sig = slh_dsa_128s_sign(&priv_key, msg).unwrap();
|
||||
assert_eq!(sig.len(), 7856);
|
||||
assert!(slh_dsa_128s_verify(&pub_key, msg, &sig));
|
||||
assert!(!slh_dsa_128s_verify(&pub_key, b"wrong message", &sig));
|
||||
|
||||
// Deterministic signing: same key + msg → same signature
|
||||
// (opt_rand defaults to pk.seed).
|
||||
let sig2 = slh_dsa_128s_sign(&priv_key, msg).unwrap();
|
||||
assert_eq!(sig, sig2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ml_kem_768_roundtrip() {
|
||||
let seed = [0x42u8; 64];
|
||||
let (priv_key, pub_key) = ml_kem_768_keygen_from_seed(&seed).unwrap();
|
||||
assert_eq!(priv_key.len(), 64);
|
||||
assert_eq!(pub_key.len(), 1184);
|
||||
|
||||
// Determinism.
|
||||
let (priv2, pub2) = ml_kem_768_keygen_from_seed(&seed).unwrap();
|
||||
assert_eq!(priv_key, priv2);
|
||||
assert_eq!(pub_key, pub2);
|
||||
|
||||
// Encaps/decaps roundtrip.
|
||||
let (ct, ss_send) = ml_kem_768_encaps(&pub_key).unwrap();
|
||||
assert_eq!(ct.len(), 1088);
|
||||
assert_eq!(ss_send.len(), 32);
|
||||
let ss_recv = ml_kem_768_decaps(&priv_key, &ct).unwrap();
|
||||
assert_eq!(ss_send, ss_recv);
|
||||
|
||||
// Encapsulation is randomized: two calls → different ciphertexts.
|
||||
let (ct2, ss2) = ml_kem_768_encaps(&pub_key).unwrap();
|
||||
assert_ne!(ct, ct2);
|
||||
assert_ne!(ss_send.to_vec(), ss2.to_vec());
|
||||
assert_eq!(ml_kem_768_decaps(&priv_key, &ct2).unwrap(), ss2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ml_kem_768_invalid_inputs() {
|
||||
assert!(ml_kem_768_keygen_from_seed(&[0u8; 32]).is_err());
|
||||
assert!(ml_kem_768_decaps(&[0u8; 64], &[0u8; 1087]).is_err());
|
||||
assert!(ml_kem_768_encaps(&[0u8; 1183]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -1,7 +1,14 @@
|
||||
//! Deterministic PRNG for post-quantum key generation.
|
||||
//! Deterministic PRNG (NOT used for PQ key derivation).
|
||||
//!
|
||||
//! Port of `pq_drbg.c`. Implements a SHAKE-256-based deterministic PRNG
|
||||
//! that replaces PQClean's `randombytes()` callback. Same seed → same output.
|
||||
//! that replaced PQClean's `randombytes()` callback in the C n_signer.
|
||||
//! Same seed → same output.
|
||||
//!
|
||||
//! **Not used for derivation**: PQ keygen now uses the v2 FIPS seeded
|
||||
//! interface (see `plans/pq_seeded_derivation_plan.md`) — BIP-32 child
|
||||
//! bytes at the exact seed length feed the seeded keygen APIs directly.
|
||||
//! This module is retained as a faithful port for any future
|
||||
//! PQClean-style integration that needs an RNG-fed keygen.
|
||||
|
||||
use sha3::{Shake256, digest::{Update, ExtendableOutput, XofReader}};
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Cross-implementation PQ keygen conformance.
|
||||
//!
|
||||
//! Validates the v2 seeded derivation against the pinned vectors from
|
||||
//! nostr_quantum_preparation (`test/vectors/seed-to-pubkeys.v2.json`):
|
||||
//! the same fixed mnemonic must produce the same PQ public keys in the
|
||||
//! web app (noble) and here (RustCrypto crates).
|
||||
//!
|
||||
//! The test is skipped (passes with a note) when the vector file does not
|
||||
//! exist yet — coordinate generation with the web app project
|
||||
//! (`test/vectors/generate-vectors.mjs`).
|
||||
|
||||
use signer::pq_crypto;
|
||||
|
||||
const VECTOR_PATH: &str = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../nostr_quantum_preparation/test/vectors/seed-to-pubkeys.v2.json"
|
||||
);
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Vector {
|
||||
mnemonic: String,
|
||||
#[serde(rename = "derivedPublicKeys")]
|
||||
derived_public_keys: DerivedPublicKeys,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct DerivedPublicKeys {
|
||||
#[serde(rename = "ml-dsa-65")]
|
||||
ml_dsa_65: KeyEntry,
|
||||
#[serde(rename = "slh-dsa-128s")]
|
||||
slh_dsa_128s: KeyEntry,
|
||||
#[serde(rename = "ml-kem-768")]
|
||||
ml_kem_768: KeyEntry,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct KeyEntry {
|
||||
#[serde(rename = "derivationPath")]
|
||||
derivation_path: String,
|
||||
#[serde(rename = "publicKeyHex")]
|
||||
public_key_hex: String,
|
||||
}
|
||||
|
||||
/// Extract the base path (strip the trailing leaf index and any sibling
|
||||
/// notation like "+ m/44'/102004'/0'/0'/1'").
|
||||
fn base_path(full: &str) -> String {
|
||||
full.split(" + ").next().unwrap_or(full).to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pq_pubkeys_match_web_app_v2_vectors() {
|
||||
let data = match std::fs::read_to_string(VECTOR_PATH) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
eprintln!("SKIP: {VECTOR_PATH} not found — generate it in nostr_quantum_preparation");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let vector: Vector = serde_json::from_str(&data)
|
||||
.expect("valid v2 vector JSON");
|
||||
|
||||
// ML-DSA-65: 32-byte seed from one child.
|
||||
let seed = pq_crypto::derive_pq_seed_from_path(
|
||||
&vector.mnemonic,
|
||||
&base_path(&vector.derived_public_keys.ml_dsa_65.derivation_path),
|
||||
32,
|
||||
)
|
||||
.unwrap();
|
||||
let seed_arr: [u8; 32] = seed.as_slice().try_into().unwrap();
|
||||
let (_, pub_key) = pq_crypto::ml_dsa_65_keygen_from_seed(&seed_arr).unwrap();
|
||||
assert_eq!(
|
||||
hex::encode(&pub_key),
|
||||
vector.derived_public_keys.ml_dsa_65.public_key_hex,
|
||||
"ml-dsa-65 pubkey must match the web app vector"
|
||||
);
|
||||
|
||||
// SLH-DSA-128s: 48-byte seed from two children (first 48 of 64).
|
||||
let seed = pq_crypto::derive_pq_seed_from_path(
|
||||
&vector.mnemonic,
|
||||
&base_path(&vector.derived_public_keys.slh_dsa_128s.derivation_path),
|
||||
48,
|
||||
)
|
||||
.unwrap();
|
||||
let (_, pub_key) = pq_crypto::slh_dsa_128s_keygen_from_seed(&seed).unwrap();
|
||||
assert_eq!(
|
||||
hex::encode(&pub_key),
|
||||
vector.derived_public_keys.slh_dsa_128s.public_key_hex,
|
||||
"slh-dsa-128s pubkey must match the web app vector"
|
||||
);
|
||||
|
||||
// ML-KEM-768: 64-byte seed from two children.
|
||||
let seed = pq_crypto::derive_pq_seed_from_path(
|
||||
&vector.mnemonic,
|
||||
&base_path(&vector.derived_public_keys.ml_kem_768.derivation_path),
|
||||
64,
|
||||
)
|
||||
.unwrap();
|
||||
let (_, pub_key) = pq_crypto::ml_kem_768_keygen_from_seed(&seed).unwrap();
|
||||
assert_eq!(
|
||||
hex::encode(&pub_key),
|
||||
vector.derived_public_keys.ml_kem_768.public_key_hex,
|
||||
"ml-kem-768 pubkey must match the web app vector"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user