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    /// Sign arbitrary `msg` bytes, returning the lowercase-hex of the
127    /// 64-byte Ed25519 signature. Unlike [`Keypair::sign_stage_id`], the
128    /// caller controls the exact bytes — used for signing a capability
129    /// contract's domain-separated payload (`lex pkg publish`), which is
130    /// not a StageId. Verify with [`verify_message`].
131    pub fn sign_message(&self, msg: &[u8]) -> String {
132        hex::encode(self.inner.sign(msg).to_bytes())
133    }
134}
135
136/// Verify that `signature` is a valid Ed25519 signature over the
137/// UTF-8 bytes of `stage_id`, produced by the holder of the private
138/// key matching `signature.public_key`.
139///
140/// Returns `Ok(())` on success. The signature record is otherwise
141/// untrusted input: a callsite that wants to enforce *which* key
142/// signed has to check `signature.public_key` against an allowlist
143/// before or after this call.
144pub fn verify_stage_id(stage_id: &str, signature: &Signature) -> Result<(), SigningError> {
145    const PK_HEX_LEN: usize = 64;
146    const SIG_HEX_LEN: usize = 128;
147    if signature.public_key.len() != PK_HEX_LEN {
148        return Err(SigningError::PublicKeyLength {
149            expected: PK_HEX_LEN, got: signature.public_key.len(),
150        });
151    }
152    if signature.signature.len() != SIG_HEX_LEN {
153        return Err(SigningError::SignatureLength {
154            expected: SIG_HEX_LEN, got: signature.signature.len(),
155        });
156    }
157    let pk_bytes = hex::decode(&signature.public_key)
158        .map_err(|e| SigningError::BadHex(e.to_string()))?;
159    let sig_bytes = hex::decode(&signature.signature)
160        .map_err(|e| SigningError::BadHex(e.to_string()))?;
161    let pk_arr: [u8; 32] = pk_bytes.try_into().expect("length-checked");
162    let sig_arr: [u8; 64] = sig_bytes.try_into().expect("length-checked");
163    let pk = VerifyingKey::from_bytes(&pk_arr)
164        .map_err(|_| SigningError::BadPublicKey)?;
165    let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
166    pk.verify(stage_id.as_bytes(), &sig)
167        .map_err(|_| SigningError::VerifyFailed)
168}
169
170/// Verify a raw Ed25519 signature (lowercase hex, 128 chars) over `msg`
171/// against `public_key_hex` (64 chars). The byte-level twin of
172/// [`verify_stage_id`] for callers that signed a domain-separated
173/// payload rather than a StageId string (e.g. a capability contract).
174pub fn verify_message(
175    public_key_hex: &str,
176    msg: &[u8],
177    signature_hex: &str,
178) -> Result<(), SigningError> {
179    const PK_HEX_LEN: usize = 64;
180    const SIG_HEX_LEN: usize = 128;
181    if public_key_hex.len() != PK_HEX_LEN {
182        return Err(SigningError::PublicKeyLength {
183            expected: PK_HEX_LEN,
184            got: public_key_hex.len(),
185        });
186    }
187    if signature_hex.len() != SIG_HEX_LEN {
188        return Err(SigningError::SignatureLength {
189            expected: SIG_HEX_LEN,
190            got: signature_hex.len(),
191        });
192    }
193    let pk_bytes =
194        hex::decode(public_key_hex).map_err(|e| SigningError::BadHex(e.to_string()))?;
195    let sig_bytes =
196        hex::decode(signature_hex).map_err(|e| SigningError::BadHex(e.to_string()))?;
197    let pk_arr: [u8; 32] = pk_bytes.try_into().expect("length-checked");
198    let sig_arr: [u8; 64] = sig_bytes.try_into().expect("length-checked");
199    let pk = VerifyingKey::from_bytes(&pk_arr).map_err(|_| SigningError::BadPublicKey)?;
200    let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
201    pk.verify(msg, &sig).map_err(|_| SigningError::VerifyFailed)
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    fn fixture() -> Keypair {
209        // Deterministic seed so test vectors are reproducible.
210        Keypair::from_seed(&[7u8; 32])
211    }
212
213    #[test]
214    fn generate_produces_distinct_keys() {
215        let a = Keypair::generate().unwrap();
216        let b = Keypair::generate().unwrap();
217        assert_ne!(a.public_hex(), b.public_hex(),
218            "two keygen calls must not produce identical keys");
219        assert_ne!(a.secret_hex(), b.secret_hex());
220    }
221
222    #[test]
223    fn public_and_secret_hex_lengths_are_canonical() {
224        let kp = fixture();
225        assert_eq!(kp.public_hex().len(), 64);
226        assert_eq!(kp.secret_hex().len(), 64);
227        assert!(kp.public_hex().chars().all(|c| c.is_ascii_hexdigit()));
228        assert!(kp.secret_hex().chars().all(|c| c.is_ascii_hexdigit()));
229    }
230
231    #[test]
232    fn sign_then_verify_round_trips() {
233        let kp = fixture();
234        let stage_id = "deadbeefcafef00d";
235        let sig = kp.sign_stage_id(stage_id);
236        assert_eq!(sig.public_key, kp.public_hex());
237        assert_eq!(sig.signature.len(), 128);
238        verify_stage_id(stage_id, &sig).expect("signature must verify");
239    }
240
241    #[test]
242    fn signing_is_deterministic_for_same_input() {
243        // Ed25519 is deterministic by RFC 8032: same key + same
244        // message ⇒ same signature. The agent-attestation story
245        // benefits from this — two harnesses signing the same
246        // StageId with the same key produce byte-identical evidence.
247        let kp = fixture();
248        let s1 = kp.sign_stage_id("stage-abc");
249        let s2 = kp.sign_stage_id("stage-abc");
250        assert_eq!(s1, s2);
251    }
252
253    #[test]
254    fn different_stage_ids_produce_different_signatures() {
255        let kp = fixture();
256        let a = kp.sign_stage_id("stage-A");
257        let b = kp.sign_stage_id("stage-B");
258        assert_ne!(a.signature, b.signature);
259    }
260
261    #[test]
262    fn verification_rejects_tampered_stage_id() {
263        let kp = fixture();
264        let sig = kp.sign_stage_id("real-stage-id");
265        let err = verify_stage_id("forged-stage-id", &sig).unwrap_err();
266        assert!(matches!(err, SigningError::VerifyFailed),
267            "tampered stage_id must fail verification, got {err:?}");
268    }
269
270    #[test]
271    fn verification_rejects_wrong_public_key() {
272        // The signature was made by `kp_a` but is presented under
273        // `kp_b`'s public key — a classic substitution attempt.
274        let kp_a = Keypair::from_seed(&[1u8; 32]);
275        let kp_b = Keypair::from_seed(&[2u8; 32]);
276        let mut sig = kp_a.sign_stage_id("stage-id");
277        sig.public_key = kp_b.public_hex();
278        let err = verify_stage_id("stage-id", &sig).unwrap_err();
279        assert!(matches!(err, SigningError::VerifyFailed));
280    }
281
282    #[test]
283    fn from_secret_hex_round_trips() {
284        let original = Keypair::from_seed(&[42u8; 32]);
285        let hex_secret = original.secret_hex();
286        let parsed = Keypair::from_secret_hex(&hex_secret).unwrap();
287        assert_eq!(original.public_hex(), parsed.public_hex());
288        // And signatures are bit-identical given Ed25519 determinism.
289        assert_eq!(
290            original.sign_stage_id("x"),
291            parsed.sign_stage_id("x"),
292        );
293    }
294
295    #[test]
296    fn from_secret_hex_rejects_wrong_length() {
297        let err = Keypair::from_secret_hex("deadbeef").unwrap_err();
298        assert!(matches!(err, SigningError::SecretKeyLength { .. }));
299    }
300
301    #[test]
302    fn from_secret_hex_rejects_invalid_hex() {
303        let bad = "z".repeat(64);
304        let err = Keypair::from_secret_hex(&bad).unwrap_err();
305        assert!(matches!(err, SigningError::BadHex(_)));
306    }
307
308    #[test]
309    fn verify_rejects_malformed_signature_lengths() {
310        let mut sig = fixture().sign_stage_id("x");
311        sig.signature = "deadbeef".into();
312        let err = verify_stage_id("x", &sig).unwrap_err();
313        assert!(matches!(err, SigningError::SignatureLength { .. }));
314    }
315
316    #[test]
317    fn verify_rejects_malformed_public_key_lengths() {
318        let mut sig = fixture().sign_stage_id("x");
319        sig.public_key = "deadbeef".into();
320        let err = verify_stage_id("x", &sig).unwrap_err();
321        assert!(matches!(err, SigningError::PublicKeyLength { .. }));
322    }
323}