Skip to main content

basil_core/core/
ed25519_sign.rs

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