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}