Skip to main content

basil_core/core/
ed25519_sign.rs

1//! Ed25519 materialize-to-sign: the value-store signing crypto core.
2//!
3//! The self-contained primitive for the materialize-to-sign arm (vault-iiz,
4//! design §17.7: the materialize-to-use local-custody arm; sibling of
5//! [`crate::core::x25519_seal`]).
6//!
7//! `OpenBao`/Vault transit signs an Ed25519 key *in place*: the private never
8//! leaves the backend. But a backend engine with no in-place sign primitive (a
9//! plain value store, KV v2) can only hand back the raw private bytes. For that
10//! one sanctioned case the design (secrets-vault §17.7) answer is to
11//! **materialize** the 32-byte Ed25519 seed from KV in-process, perform the one
12//! signature, then **zeroize** it. This module is the crypto core for that path:
13//! it takes raw seed bytes and has **no** backend/Bao dependency, so the
14//! construction is unit-testable fully offline.
15//!
16//! # Custody discipline
17//!
18//! - The materialized seed is held only in a [`Zeroizing`] fixed array, wiped on
19//!   drop on the success **and** error paths.
20//! - The ed25519-dalek `SigningKey` built from it zeroizes its own secret scalar
21//!   on drop (the `zeroize` feature gives it `ZeroizeOnDrop`).
22//! - No plaintext seed copy ever escapes into a non-zeroizing owner, an error
23//!   string, a `Debug`/`Display`, a log, or the audit record. There is no type in
24//!   this module that derives `Debug` over secret bytes.
25//!
26//! # Verification is a public op
27//!
28//! [`verify`] and [`public_from_seed`] need only the public half. As of
29//! basil-o86 the broker reads the **out-of-band-provisioned** public (via
30//! [`public_from_slice`]) for `verify`/`get_public_key`, so the seed is
31//! materialized **only** on `sign`: the one op that performs the private crypto.
32//! [`public_from_seed`] remains the canonical seed→public derivation used to
33//! provision that out-of-band public and to anchor the KAT round-trip tests.
34
35use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
36use zeroize::Zeroizing;
37
38/// Length of an Ed25519 seed / private (bytes). The seed is the 32-byte input to
39/// the key expansion; ed25519-dalek's `SigningKey` is built directly from it.
40pub const SEED_LEN: usize = 32;
41/// Length of an Ed25519 public / verifying key (bytes).
42pub const PUBLIC_KEY_LEN: usize = 32;
43/// Length of an Ed25519 signature (bytes).
44pub const SIGNATURE_LEN: usize = 64;
45
46/// Why a materialize-to-sign helper rejected its input.
47///
48/// Deliberately carries **no** secret material: the only failure modes are
49/// fixed-length validation faults, each reporting the lengths involved (never any
50/// byte of the seed, signature, or message).
51#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
52pub enum SignError {
53    /// The materialized seed slice was not exactly [`SEED_LEN`] bytes, a
54    /// misprovisioned KV value. Fails closed before any key construction.
55    #[error("invalid seed length: expected {expected} bytes, got {actual}")]
56    BadSeedLength {
57        /// The required length.
58        expected: usize,
59        /// The length actually supplied.
60        actual: usize,
61    },
62
63    /// A public key or signature slice was the wrong length on the verify path:
64    /// attacker-influenced bytes, validated before any crypto.
65    #[error("invalid {what} length: expected {expected} bytes, got {actual}")]
66    BadFieldLength {
67        /// Which field (`"public key"` / `"signature"`).
68        what: &'static str,
69        /// The required length.
70        expected: usize,
71        /// The length actually supplied.
72        actual: usize,
73    },
74}
75
76/// Wrap a raw seed byte slice into the zeroizing fixed array the sign/derive path
77/// expects, failing closed (never indexing/`unwrap`-ing) on a wrong length.
78///
79/// Used by the manager when it materializes the seed bytes out of KV.
80///
81/// # Errors
82///
83/// [`SignError::BadSeedLength`] if `bytes` is not exactly [`SEED_LEN`].
84pub fn seed_from_slice(bytes: &[u8]) -> Result<Zeroizing<[u8; SEED_LEN]>, SignError> {
85    let seed: [u8; SEED_LEN] = bytes.try_into().map_err(|_| SignError::BadSeedLength {
86        expected: SEED_LEN,
87        actual: bytes.len(),
88    })?;
89    Ok(Zeroizing::new(seed))
90}
91
92/// Build the ed25519-dalek `SigningKey` from a materialized seed. The returned
93/// key zeroizes its secret scalar on drop (the `zeroize` feature). Kept private so
94/// the secret-bearing key never crosses the module boundary.
95fn signing_key(seed: &Zeroizing<[u8; SEED_LEN]>) -> SigningKey {
96    SigningKey::from_bytes(seed)
97}
98
99/// Sign `message` with a materialized Ed25519 `seed`, returning the 64-byte
100/// signature.
101///
102/// The `SigningKey` is built from the seed, used for exactly one signature, and
103/// dropped (zeroized) before this returns. The signature bytes carry no secret
104/// material. This is infallible for a valid 32-byte seed: ed25519 signing cannot
105/// fail on in-range inputs, so there is no error path that could leak detail.
106#[must_use]
107pub fn sign(seed: &Zeroizing<[u8; SEED_LEN]>, message: &[u8]) -> [u8; SIGNATURE_LEN] {
108    let key = signing_key(seed);
109    key.sign(message).to_bytes()
110}
111
112/// Derive the Ed25519 **public** (verifying) key from a materialized seed.
113///
114/// The public half is derived from the seed and only the public bytes are
115/// returned; the seed is never serialized. As of basil-o86 the broker no longer
116/// calls this on the per-op `verify`/`get_public_key` paths (they read the
117/// out-of-band public via [`public_from_slice`] without materializing the seed).
118/// It is the canonical seed→public derivation used to **provision** that
119/// out-of-band public and to anchor the RFC 8032 KAT round-trip tests.
120#[must_use]
121pub fn public_from_seed(seed: &Zeroizing<[u8; SEED_LEN]>) -> [u8; PUBLIC_KEY_LEN] {
122    signing_key(seed).verifying_key().to_bytes()
123}
124
125/// Validate a raw 32-byte **public** (verifying) key slice into a fixed array,
126/// failing closed (never indexing) on a wrong length.
127///
128/// Used by the manager when it reads the signing key's public, provisioned out
129/// of band (basil-o86), from KV for `verify`/`get_public_key`, so the seed is
130/// **never** materialized for those public ops. The public carries no secret, so
131/// the array is a plain `[u8; 32]` (not `Zeroizing`).
132///
133/// # Errors
134///
135/// [`SignError::BadFieldLength`] if `bytes` is not exactly [`PUBLIC_KEY_LEN`].
136pub fn public_from_slice(bytes: &[u8]) -> Result<[u8; PUBLIC_KEY_LEN], SignError> {
137    bytes.try_into().map_err(|_| SignError::BadFieldLength {
138        what: "public key",
139        expected: PUBLIC_KEY_LEN,
140        actual: bytes.len(),
141    })
142}
143
144/// Verify `signature` over `message` against an Ed25519 `public` key.
145///
146/// A **public** operation: it never needs the seed. Returns `Ok(true)` on a valid
147/// signature, `Ok(false)` on an authentication failure (wrong key / tampered
148/// signature or message), and an error only on a malformed (wrong-length or
149/// non-canonical) public key, validated before any crypto, never indexing into
150/// attacker bytes.
151///
152/// # Errors
153///
154/// [`SignError::BadFieldLength`] if `public` or `signature` is the wrong length.
155pub fn verify(
156    public: &[u8; PUBLIC_KEY_LEN],
157    message: &[u8],
158    signature: &[u8],
159) -> Result<bool, SignError> {
160    // Reject a wrong-length signature before constructing anything. The dalek
161    // `Signature` is a fixed [u8;64], so convert fail-closed (no index/unwrap).
162    let sig_bytes: [u8; SIGNATURE_LEN] =
163        signature
164            .try_into()
165            .map_err(|_| SignError::BadFieldLength {
166                what: "signature",
167                expected: SIGNATURE_LEN,
168                actual: signature.len(),
169            })?;
170    // A non-canonical / invalid public key point is not an oracle: treat it as a
171    // failed verification (`Ok(false)`), not a distinct error, so a caller cannot
172    // distinguish "bad key encoding" from "bad signature".
173    let Ok(verifying_key) = VerifyingKey::from_bytes(public) else {
174        return Ok(false);
175    };
176    let sig = Signature::from_bytes(&sig_bytes);
177    Ok(verifying_key.verify(message, &sig).is_ok())
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    /// RFC 8032 §7.1 Test 1: the empty-message vector. The 32-byte secret seed,
185    /// the derived public, and the expected signature are the published vectors,
186    /// a known-answer test that pins our seed→key expansion + signing to the
187    /// standard (not just to a self-consistent round-trip).
188    const RFC8032_SEED: [u8; 32] = [
189        0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec, 0x2c,
190        0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03, 0x1c, 0xae,
191        0x7f, 0x60,
192    ];
193    const RFC8032_PUBLIC: [u8; 32] = [
194        0xd7, 0x5a, 0x98, 0x01, 0x82, 0xb1, 0x0a, 0xb7, 0xd5, 0x4b, 0xfe, 0xd3, 0xc9, 0x64, 0x07,
195        0x3a, 0x0e, 0xe1, 0x72, 0xf3, 0xda, 0xa6, 0x23, 0x25, 0xaf, 0x02, 0x1a, 0x68, 0xf7, 0x07,
196        0x51, 0x1a,
197    ];
198    const RFC8032_SIG: [u8; 64] = [
199        0xe5, 0x56, 0x43, 0x00, 0xc3, 0x60, 0xac, 0x72, 0x90, 0x86, 0xe2, 0xcc, 0x80, 0x6e, 0x82,
200        0x8a, 0x84, 0x87, 0x7f, 0x1e, 0xb8, 0xe5, 0xd9, 0x74, 0xd8, 0x73, 0xe0, 0x65, 0x22, 0x49,
201        0x01, 0x55, 0x5f, 0xb8, 0x82, 0x15, 0x90, 0xa3, 0x3b, 0xac, 0xc6, 0x1e, 0x39, 0x70, 0x1c,
202        0xf9, 0xb4, 0x6b, 0xd2, 0x5b, 0xf5, 0xf0, 0x59, 0x5b, 0xbe, 0x24, 0x65, 0x51, 0x41, 0x43,
203        0x8e, 0x7a, 0x10, 0x0b,
204    ];
205
206    #[test]
207    fn sign_matches_rfc8032_known_answer() {
208        let seed = Zeroizing::new(RFC8032_SEED);
209        assert_eq!(public_from_seed(&seed), RFC8032_PUBLIC);
210        // Empty message vector.
211        assert_eq!(sign(&seed, b""), RFC8032_SIG);
212    }
213
214    #[test]
215    fn sign_then_verify_round_trips() {
216        let seed = Zeroizing::new([7u8; 32]);
217        let public = public_from_seed(&seed);
218        let message = b"materialize-to-sign payload";
219        let sig = sign(&seed, message);
220        assert_eq!(verify(&public, message, &sig), Ok(true));
221    }
222
223    #[test]
224    fn sign_is_deterministic() {
225        // Ed25519 is deterministic: the same seed + message yields the same sig.
226        let seed = Zeroizing::new([3u8; 32]);
227        assert_eq!(sign(&seed, b"m"), sign(&seed, b"m"));
228    }
229
230    #[test]
231    fn public_from_seed_is_deterministic() {
232        let seed = Zeroizing::new([5u8; 32]);
233        assert_eq!(public_from_seed(&seed), public_from_seed(&seed));
234    }
235
236    #[test]
237    fn verify_rejects_wrong_key() {
238        let seed = Zeroizing::new([7u8; 32]);
239        let other_public = public_from_seed(&Zeroizing::new([42u8; 32]));
240        let sig = sign(&seed, b"payload");
241        // A signature under one key never verifies under a different public key.
242        assert_eq!(verify(&other_public, b"payload", &sig), Ok(false));
243    }
244
245    #[test]
246    fn verify_rejects_tampered_signature() {
247        let seed = Zeroizing::new([7u8; 32]);
248        let public = public_from_seed(&seed);
249        let mut sig = sign(&seed, b"payload");
250        sig[0] ^= 0xFF;
251        assert_eq!(verify(&public, b"payload", &sig), Ok(false));
252    }
253
254    #[test]
255    fn verify_rejects_tampered_message() {
256        let seed = Zeroizing::new([7u8; 32]);
257        let public = public_from_seed(&seed);
258        let sig = sign(&seed, b"payload");
259        assert_eq!(verify(&public, b"payload-tampered", &sig), Ok(false));
260    }
261
262    #[test]
263    fn verify_rejects_wrong_length_signature() {
264        let seed = Zeroizing::new([7u8; 32]);
265        let public = public_from_seed(&seed);
266        assert!(matches!(
267            verify(&public, b"m", &[0u8; 63]),
268            Err(SignError::BadFieldLength {
269                what: "signature",
270                ..
271            })
272        ));
273        assert!(matches!(
274            verify(&public, b"m", &[0u8; 65]),
275            Err(SignError::BadFieldLength { .. })
276        ));
277    }
278
279    #[test]
280    fn verify_treats_bad_public_key_as_failed_not_an_oracle() {
281        // An all-FF public key is not a canonical Ed25519 point. verify must report
282        // a plain `Ok(false)` (no oracle distinguishing it from a bad signature).
283        let seed = Zeroizing::new([7u8; 32]);
284        let sig = sign(&seed, b"m");
285        assert_eq!(verify(&[0xFFu8; 32], b"m", &sig), Ok(false));
286    }
287
288    #[test]
289    fn seed_from_slice_rejects_wrong_length() {
290        assert!(matches!(
291            seed_from_slice(&[0u8; 31]),
292            Err(SignError::BadSeedLength { .. })
293        ));
294        assert!(matches!(
295            seed_from_slice(&[0u8; 33]),
296            Err(SignError::BadSeedLength { .. })
297        ));
298        assert!(seed_from_slice(&[0u8; 32]).is_ok());
299    }
300
301    #[test]
302    fn public_from_slice_validates_length_and_verifies() {
303        // The out-of-band public read (basil-o86) validates the stored bytes into a
304        // fixed array, failing closed on a wrong length and never indexing.
305        assert!(matches!(
306            public_from_slice(&[0u8; 31]),
307            Err(SignError::BadFieldLength {
308                what: "public key",
309                ..
310            })
311        ));
312        assert!(matches!(
313            public_from_slice(&[0u8; 33]),
314            Err(SignError::BadFieldLength { .. })
315        ));
316        // A valid 32-byte public verifies a signature exactly like the in-array
317        // public derived from the seed.
318        let seed = Zeroizing::new([7u8; 32]);
319        let public = public_from_seed(&seed);
320        let from_slice = public_from_slice(&public).expect("32-byte public");
321        assert_eq!(from_slice, public);
322        let sig = sign(&seed, b"from-out-of-band-public");
323        assert_eq!(
324            verify(&from_slice, b"from-out-of-band-public", &sig),
325            Ok(true)
326        );
327    }
328
329    #[test]
330    fn seed_from_slice_round_trips_through_sign() {
331        // The realistic materialize path: a KV byte slice -> fixed seed -> sign.
332        let stored = vec![0x11u8; 32];
333        let seed = seed_from_slice(&stored).expect("valid seed");
334        let public = public_from_seed(&seed);
335        let sig = sign(&seed, b"from-kv");
336        assert_eq!(verify(&public, b"from-kv", &sig), Ok(true));
337    }
338}