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}