Skip to main content

lex_vcs/
signing.rs

1//! Ed25519 signing for stage authorship (#227).
2//!
3//! Lex's auditability story depends on knowing *who* published what.
4//! Content addressing answers "what did this stage say"; signing
5//! answers "who said it." A consumer can refuse stages that aren't
6//! signed (or that aren't signed by a trusted key) and the trail is
7//! cryptographic, not policy-only.
8//!
9//! # What gets signed
10//!
11//! The bytes of the [`StageId`] string itself — UTF-8 of the
12//! lowercase-hex SHA-256 that already content-addresses the stage.
13//! Signing the `StageId` instead of the AST means:
14//!
15//! * Independent of canonical-AST format changes: a future migration
16//!   to CBOR for the wire format doesn't invalidate signatures.
17//! * Cheap to verify: 64 hex chars in, single Ed25519 verify out.
18//! * Cross-tool reproducible: any tool that can compute the StageId
19//!   can verify the signature without parsing the AST.
20//!
21//! This matches Noether's approach so cross-ecosystem verification
22//! stays possible.
23//!
24//! # Hex encoding
25//!
26//! Public keys are 32 bytes → 64 hex chars. Signatures are 64 bytes
27//! → 128 hex chars. Lowercase, no `0x` prefix, matching the
28//! existing convention for [`crate::OpId`] / [`crate::StageId`].
29
30use crate::attestation::Signature;
31use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey, SECRET_KEY_LENGTH};
32
33/// Errors surfaced when keys, signatures, or verification go wrong.
34#[derive(Debug, thiserror::Error)]
35pub enum SigningError {
36    #[error("system entropy unavailable: {0}")]
37    Entropy(String),
38    #[error("public key must be {expected} hex chars (32 bytes), got {got}")]
39    PublicKeyLength { expected: usize, got: usize },
40    #[error("secret key must be {expected} hex chars (32 bytes), got {got}")]
41    SecretKeyLength { expected: usize, got: usize },
42    #[error("signature must be {expected} hex chars (64 bytes), got {got}")]
43    SignatureLength { expected: usize, got: usize },
44    #[error("invalid hex: {0}")]
45    BadHex(String),
46    #[error("invalid public key bytes")]
47    BadPublicKey,
48    #[error("signature did not verify against the given public key and stage id")]
49    VerifyFailed,
50}
51
52/// An Ed25519 keypair held in memory. The secret key is `[u8; 32]`
53/// — the 32-byte seed Ed25519 expands into a signing key. We never
54/// keep the expanded scalar around; every sign call regenerates it
55/// from the seed.
56pub struct Keypair {
57    inner: SigningKey,
58}
59
60impl std::fmt::Debug for Keypair {
61    // The secret seed never appears in Debug output. A panic message
62    // or stray `{:?}` should never be the path that leaks the key.
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.debug_struct("Keypair")
65            .field("public_key", &self.public_hex())
66            .field("secret_key", &"<redacted>")
67            .finish()
68    }
69}
70
71impl Keypair {
72    /// Generate a fresh keypair using the OS CSPRNG. Suitable for
73    /// `lex keygen`; production callers wanting deterministic keys
74    /// (test vectors, KAT) should use [`Keypair::from_secret_hex`].
75    pub fn generate() -> Result<Self, SigningError> {
76        let mut seed = [0u8; SECRET_KEY_LENGTH];
77        getrandom::fill(&mut seed)
78            .map_err(|e| SigningError::Entropy(e.to_string()))?;
79        Ok(Self::from_seed(&seed))
80    }
81
82    /// Build a keypair from a 32-byte seed. Useful for tests where a
83    /// known fixture key is needed; production callers should prefer
84    /// [`Keypair::generate`] or the hex-deserialise path.
85    pub fn from_seed(seed: &[u8; SECRET_KEY_LENGTH]) -> Self {
86        Self { inner: SigningKey::from_bytes(seed) }
87    }
88
89    /// Parse a hex-encoded 32-byte secret key (the seed).
90    pub fn from_secret_hex(hex_str: &str) -> Result<Self, SigningError> {
91        const EXPECTED: usize = SECRET_KEY_LENGTH * 2;
92        if hex_str.len() != EXPECTED {
93            return Err(SigningError::SecretKeyLength {
94                expected: EXPECTED, got: hex_str.len()
95            });
96        }
97        let bytes = hex::decode(hex_str)
98            .map_err(|e| SigningError::BadHex(e.to_string()))?;
99        let arr: [u8; SECRET_KEY_LENGTH] = bytes.try_into()
100            .expect("length-checked above");
101        Ok(Self::from_seed(&arr))
102    }
103
104    /// Lowercase-hex of the 32-byte secret seed. Treat as material —
105    /// this is what `lex keygen` prints to stdout exactly once.
106    pub fn secret_hex(&self) -> String {
107        hex::encode(self.inner.to_bytes())
108    }
109
110    /// Lowercase-hex of the 32-byte public key. Safe to publish.
111    pub fn public_hex(&self) -> String {
112        hex::encode(self.inner.verifying_key().to_bytes())
113    }
114
115    /// Sign the UTF-8 bytes of a `StageId` and return the wire-format
116    /// [`Signature`] record. The same record verifies via
117    /// [`verify_stage_id`].
118    pub fn sign_stage_id(&self, stage_id: &str) -> Signature {
119        let sig = self.inner.sign(stage_id.as_bytes());
120        Signature {
121            public_key: self.public_hex(),
122            signature: hex::encode(sig.to_bytes()),
123        }
124    }
125}
126
127/// Verify that `signature` is a valid Ed25519 signature over the
128/// UTF-8 bytes of `stage_id`, produced by the holder of the private
129/// key matching `signature.public_key`.
130///
131/// Returns `Ok(())` on success. The signature record is otherwise
132/// untrusted input: a callsite that wants to enforce *which* key
133/// signed has to check `signature.public_key` against an allowlist
134/// before or after this call.
135pub fn verify_stage_id(stage_id: &str, signature: &Signature) -> Result<(), SigningError> {
136    const PK_HEX_LEN: usize = 64;
137    const SIG_HEX_LEN: usize = 128;
138    if signature.public_key.len() != PK_HEX_LEN {
139        return Err(SigningError::PublicKeyLength {
140            expected: PK_HEX_LEN, got: signature.public_key.len(),
141        });
142    }
143    if signature.signature.len() != SIG_HEX_LEN {
144        return Err(SigningError::SignatureLength {
145            expected: SIG_HEX_LEN, got: signature.signature.len(),
146        });
147    }
148    let pk_bytes = hex::decode(&signature.public_key)
149        .map_err(|e| SigningError::BadHex(e.to_string()))?;
150    let sig_bytes = hex::decode(&signature.signature)
151        .map_err(|e| SigningError::BadHex(e.to_string()))?;
152    let pk_arr: [u8; 32] = pk_bytes.try_into().expect("length-checked");
153    let sig_arr: [u8; 64] = sig_bytes.try_into().expect("length-checked");
154    let pk = VerifyingKey::from_bytes(&pk_arr)
155        .map_err(|_| SigningError::BadPublicKey)?;
156    let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
157    pk.verify(stage_id.as_bytes(), &sig)
158        .map_err(|_| SigningError::VerifyFailed)
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    fn fixture() -> Keypair {
166        // Deterministic seed so test vectors are reproducible.
167        Keypair::from_seed(&[7u8; 32])
168    }
169
170    #[test]
171    fn generate_produces_distinct_keys() {
172        let a = Keypair::generate().unwrap();
173        let b = Keypair::generate().unwrap();
174        assert_ne!(a.public_hex(), b.public_hex(),
175            "two keygen calls must not produce identical keys");
176        assert_ne!(a.secret_hex(), b.secret_hex());
177    }
178
179    #[test]
180    fn public_and_secret_hex_lengths_are_canonical() {
181        let kp = fixture();
182        assert_eq!(kp.public_hex().len(), 64);
183        assert_eq!(kp.secret_hex().len(), 64);
184        assert!(kp.public_hex().chars().all(|c| c.is_ascii_hexdigit()));
185        assert!(kp.secret_hex().chars().all(|c| c.is_ascii_hexdigit()));
186    }
187
188    #[test]
189    fn sign_then_verify_round_trips() {
190        let kp = fixture();
191        let stage_id = "deadbeefcafef00d";
192        let sig = kp.sign_stage_id(stage_id);
193        assert_eq!(sig.public_key, kp.public_hex());
194        assert_eq!(sig.signature.len(), 128);
195        verify_stage_id(stage_id, &sig).expect("signature must verify");
196    }
197
198    #[test]
199    fn signing_is_deterministic_for_same_input() {
200        // Ed25519 is deterministic by RFC 8032: same key + same
201        // message ⇒ same signature. The agent-attestation story
202        // benefits from this — two harnesses signing the same
203        // StageId with the same key produce byte-identical evidence.
204        let kp = fixture();
205        let s1 = kp.sign_stage_id("stage-abc");
206        let s2 = kp.sign_stage_id("stage-abc");
207        assert_eq!(s1, s2);
208    }
209
210    #[test]
211    fn different_stage_ids_produce_different_signatures() {
212        let kp = fixture();
213        let a = kp.sign_stage_id("stage-A");
214        let b = kp.sign_stage_id("stage-B");
215        assert_ne!(a.signature, b.signature);
216    }
217
218    #[test]
219    fn verification_rejects_tampered_stage_id() {
220        let kp = fixture();
221        let sig = kp.sign_stage_id("real-stage-id");
222        let err = verify_stage_id("forged-stage-id", &sig).unwrap_err();
223        assert!(matches!(err, SigningError::VerifyFailed),
224            "tampered stage_id must fail verification, got {err:?}");
225    }
226
227    #[test]
228    fn verification_rejects_wrong_public_key() {
229        // The signature was made by `kp_a` but is presented under
230        // `kp_b`'s public key — a classic substitution attempt.
231        let kp_a = Keypair::from_seed(&[1u8; 32]);
232        let kp_b = Keypair::from_seed(&[2u8; 32]);
233        let mut sig = kp_a.sign_stage_id("stage-id");
234        sig.public_key = kp_b.public_hex();
235        let err = verify_stage_id("stage-id", &sig).unwrap_err();
236        assert!(matches!(err, SigningError::VerifyFailed));
237    }
238
239    #[test]
240    fn from_secret_hex_round_trips() {
241        let original = Keypair::from_seed(&[42u8; 32]);
242        let hex_secret = original.secret_hex();
243        let parsed = Keypair::from_secret_hex(&hex_secret).unwrap();
244        assert_eq!(original.public_hex(), parsed.public_hex());
245        // And signatures are bit-identical given Ed25519 determinism.
246        assert_eq!(
247            original.sign_stage_id("x"),
248            parsed.sign_stage_id("x"),
249        );
250    }
251
252    #[test]
253    fn from_secret_hex_rejects_wrong_length() {
254        let err = Keypair::from_secret_hex("deadbeef").unwrap_err();
255        assert!(matches!(err, SigningError::SecretKeyLength { .. }));
256    }
257
258    #[test]
259    fn from_secret_hex_rejects_invalid_hex() {
260        let bad = "z".repeat(64);
261        let err = Keypair::from_secret_hex(&bad).unwrap_err();
262        assert!(matches!(err, SigningError::BadHex(_)));
263    }
264
265    #[test]
266    fn verify_rejects_malformed_signature_lengths() {
267        let mut sig = fixture().sign_stage_id("x");
268        sig.signature = "deadbeef".into();
269        let err = verify_stage_id("x", &sig).unwrap_err();
270        assert!(matches!(err, SigningError::SignatureLength { .. }));
271    }
272
273    #[test]
274    fn verify_rejects_malformed_public_key_lengths() {
275        let mut sig = fixture().sign_stage_id("x");
276        sig.public_key = "deadbeef".into();
277        let err = verify_stage_id("x", &sig).unwrap_err();
278        assert!(matches!(err, SigningError::PublicKeyLength { .. }));
279    }
280}