Skip to main content

wdl_modules/
signing.rs

1//! Ed25519 signing and verification, plus the `module.sig` file format.
2
3use std::fmt;
4use std::io;
5use std::io::Write;
6use std::str::FromStr;
7
8use base64::Engine as _;
9use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
10use ed25519_dalek::Signer as _;
11use ed25519_dalek::Verifier as _;
12use serde::Deserialize;
13use serde::Serialize;
14use serde_with::DeserializeFromStr;
15use serde_with::SerializeDisplay;
16use thiserror::Error;
17
18use crate::hash::ContentHash;
19
20/// An error parsing an Ed25519 key.
21#[derive(Debug, Error)]
22pub enum KeyError {
23    /// The key text could not be parsed as an OpenSSH key.
24    #[error("invalid OpenSSH key: {0}")]
25    InvalidOpenSshKey(String),
26
27    /// The key parsed but is not an Ed25519 key.
28    #[error("OpenSSH key is not an Ed25519 key")]
29    WrongAlgorithm,
30}
31
32/// An error parsing an Ed25519 signature.
33#[derive(Debug, Error)]
34pub enum SignatureError {
35    /// The signature is not valid base64.
36    #[error("signature is not valid base64")]
37    InvalidBase64,
38
39    /// The signature is not 64 bytes.
40    #[error("signature must be exactly 64 bytes; got {0}")]
41    WrongLength(usize),
42}
43
44/// An error parsing or writing a `module.sig` file.
45#[derive(Debug, Error)]
46pub enum SignatureFileError {
47    /// The file is not valid JSON.
48    #[error("invalid `module.sig` JSON")]
49    InvalidJson(#[from] serde_json::Error),
50
51    /// The `public_key` field could not be parsed as an OpenSSH Ed25519
52    /// public key.
53    #[error(transparent)]
54    Key(#[from] KeyError),
55
56    /// The `signature` field could not be parsed as a base64-encoded
57    /// 64-byte Ed25519 signature.
58    #[error(transparent)]
59    Signature(#[from] SignatureError),
60}
61
62/// An error verifying an Ed25519 signature against a content hash.
63#[derive(Debug, Error)]
64#[error("Ed25519 signature does not match the supplied content hash")]
65pub struct VerifyError;
66
67/// An Ed25519 signing key.
68#[derive(Clone, Debug)]
69pub struct SigningKey(ed25519_dalek::SigningKey);
70
71impl SigningKey {
72    /// Parses an OpenSSH-format Ed25519 private key (the contents of the
73    /// file produced by `ssh-keygen -t ed25519`).
74    pub fn from_openssh(text: &str) -> Result<Self, KeyError> {
75        let key = ssh_key::PrivateKey::from_openssh(text)
76            .map_err(|e| KeyError::InvalidOpenSshKey(e.to_string()))?;
77        let ed = key.key_data().ed25519().ok_or(KeyError::WrongAlgorithm)?;
78        let bytes: &[u8; 32] = &ed.private.to_bytes();
79        Ok(Self(ed25519_dalek::SigningKey::from_bytes(bytes)))
80    }
81
82    /// Returns the corresponding [`VerifyingKey`].
83    pub fn verifying_key(&self) -> VerifyingKey {
84        VerifyingKey(self.0.verifying_key())
85    }
86
87    /// Signs the raw 32-byte content digest of a [`ContentHash`].
88    pub fn sign(&self, digest: &ContentHash) -> Signature {
89        Signature(self.0.sign(digest.as_bytes()))
90    }
91}
92
93/// An Ed25519 verifying key.
94#[derive(Clone, Copy, Debug, PartialEq, Eq, SerializeDisplay, DeserializeFromStr)]
95pub struct VerifyingKey(ed25519_dalek::VerifyingKey);
96
97impl VerifyingKey {
98    /// Parses an OpenSSH-format Ed25519 public key (the single-line
99    /// `ssh-ed25519 <base64-blob> [comment]` form produced by
100    /// `ssh-keygen -t ed25519` in the corresponding `.pub` file). Trailing
101    /// comments are not significant.
102    pub fn from_openssh(text: &str) -> Result<Self, KeyError> {
103        let key = ssh_key::PublicKey::from_openssh(text.trim())
104            .map_err(|e| KeyError::InvalidOpenSshKey(e.to_string()))?;
105        let ed = key.key_data().ed25519().ok_or(KeyError::WrongAlgorithm)?;
106        let inner = ed25519_dalek::VerifyingKey::from_bytes(&ed.0)
107            .map_err(|e| KeyError::InvalidOpenSshKey(e.to_string()))?;
108        Ok(Self(inner))
109    }
110
111    /// Returns the canonical OpenSSH form `ssh-ed25519 <base64-blob>`,
112    /// without a trailing comment.
113    pub fn to_openssh(&self) -> String {
114        let ed = ssh_key::public::Ed25519PublicKey(*self.0.as_bytes());
115        let key = ssh_key::PublicKey::from(ssh_key::public::KeyData::Ed25519(ed));
116        // SAFETY: encoding a freshly-constructed in-memory Ed25519
117        // `PublicKey` into OpenSSH form cannot fail.
118        key.to_openssh().unwrap()
119    }
120
121    /// Verifies an Ed25519 [`Signature`] over the raw 32-byte digest of a
122    /// [`ContentHash`].
123    pub fn verify(&self, digest: &ContentHash, sig: &Signature) -> Result<(), VerifyError> {
124        self.0
125            .verify(digest.as_bytes(), &sig.0)
126            .map_err(|_| VerifyError)
127    }
128
129    /// Returns the raw 32-byte public key.
130    pub fn as_bytes(&self) -> &[u8; 32] {
131        self.0.as_bytes()
132    }
133}
134
135impl fmt::Display for VerifyingKey {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        f.write_str(&self.to_openssh())
138    }
139}
140
141impl FromStr for VerifyingKey {
142    type Err = KeyError;
143
144    fn from_str(s: &str) -> Result<Self, Self::Err> {
145        Self::from_openssh(s)
146    }
147}
148
149impl From<VerifyingKey> for String {
150    fn from(key: VerifyingKey) -> Self {
151        key.to_openssh()
152    }
153}
154
155/// An Ed25519 signature over a [`ContentHash`].
156#[derive(Clone, Copy, Debug, PartialEq, Eq, SerializeDisplay, DeserializeFromStr)]
157pub struct Signature(ed25519_dalek::Signature);
158
159impl Signature {
160    /// Parses a base64-encoded 64-byte Ed25519 signature.
161    pub fn from_base64(s: &str) -> Result<Self, SignatureError> {
162        let bytes = BASE64_STANDARD
163            .decode(s)
164            .map_err(|_| SignatureError::InvalidBase64)?;
165        let array: [u8; 64] = bytes
166            .as_slice()
167            .try_into()
168            .map_err(|_| SignatureError::WrongLength(bytes.len()))?;
169        Ok(Self(ed25519_dalek::Signature::from_bytes(&array)))
170    }
171
172    /// Returns the signature in base64 form.
173    pub fn to_base64(&self) -> String {
174        BASE64_STANDARD.encode(self.0.to_bytes())
175    }
176}
177
178impl fmt::Display for Signature {
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        f.write_str(&self.to_base64())
181    }
182}
183
184impl FromStr for Signature {
185    type Err = SignatureError;
186
187    fn from_str(s: &str) -> Result<Self, Self::Err> {
188        Self::from_base64(s)
189    }
190}
191
192impl From<Signature> for String {
193    fn from(sig: Signature) -> Self {
194        sig.to_base64()
195    }
196}
197
198/// The contents of a `module.sig` file.
199#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(deny_unknown_fields)]
201pub struct ModuleSignature {
202    /// The signer's Ed25519 public key in OpenSSH format.
203    pub public_key: VerifyingKey,
204    /// The Ed25519 signature over the module's raw 32-byte content hash.
205    pub signature: Signature,
206}
207
208impl ModuleSignature {
209    /// Parses a `module.sig` JSON document.
210    pub fn parse(bytes: &[u8]) -> Result<Self, SignatureFileError> {
211        Ok(crate::strict_json::from_slice(bytes)?)
212    }
213
214    /// Writes the signature as JSON to `w`.
215    pub fn write(&self, w: impl Write) -> io::Result<()> {
216        serde_json::to_writer_pretty(w, self).map_err(io::Error::other)
217    }
218
219    /// Verifies that `signature` is a valid signature of `digest` under
220    /// `public_key`.
221    pub fn verify(&self, digest: &ContentHash) -> Result<(), VerifyError> {
222        self.public_key.verify(digest, &self.signature)
223    }
224}
225
226/// Helpers for tests.
227#[cfg(any(test, feature = "test-utils"))]
228pub mod test_utils {
229    use sha2::Digest;
230    use sha2::Sha256;
231
232    use super::*;
233
234    /// Generates a deterministic [`SigningKey`] from a `u64` seed.
235    ///
236    /// Available only with the `test-utils` cargo feature; not part of the
237    /// production public API. Production callers should generate keys with
238    /// `ssh-keygen -t ed25519` and load them via
239    /// [`SigningKey::from_openssh`].
240    pub fn signing_key_from_seed(seed: u64) -> SigningKey {
241        let mut hasher = Sha256::new();
242        hasher.update(seed.to_le_bytes());
243        let bytes: [u8; 32] = hasher.finalize().into();
244        SigningKey(ed25519_dalek::SigningKey::from_bytes(&bytes))
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::test_utils::signing_key_from_seed;
251    use super::*;
252
253    #[test]
254    fn signs_and_verifies_round_trip() {
255        let signer = signing_key_from_seed(42);
256        let verifier = signer.verifying_key();
257        let digest = ContentHash::from([0xAB; 32]);
258
259        let sig = signer.sign(&digest);
260        verifier.verify(&digest, &sig).unwrap();
261    }
262
263    #[test]
264    fn detects_tampered_signature() {
265        let signer = signing_key_from_seed(42);
266        let verifier = signer.verifying_key();
267        let digest = ContentHash::from([0xAB; 32]);
268
269        let sig = signer.sign(&digest);
270        let tampered = ContentHash::from([0xAC; 32]);
271        assert!(verifier.verify(&tampered, &sig).is_err());
272    }
273
274    #[test]
275    fn verifying_key_round_trips_through_openssh() {
276        let signer = signing_key_from_seed(1);
277        let key = signer.verifying_key();
278        let openssh = key.to_openssh();
279        assert!(openssh.starts_with("ssh-ed25519 "));
280        let parsed = VerifyingKey::from_openssh(&openssh).unwrap();
281        assert_eq!(parsed.as_bytes(), key.as_bytes());
282    }
283
284    #[test]
285    fn verifying_key_accepts_openssh_with_comment() {
286        let signer = signing_key_from_seed(2);
287        let key = signer.verifying_key();
288        let with_comment = format!("{} user@example.com", key.to_openssh());
289        let parsed = VerifyingKey::from_openssh(&with_comment).unwrap();
290        assert_eq!(parsed.as_bytes(), key.as_bytes());
291    }
292
293    #[test]
294    fn signature_round_trips_through_base64() {
295        let signer = signing_key_from_seed(3);
296        let digest = ContentHash::from([0x11; 32]);
297        let sig = signer.sign(&digest);
298        let b64 = sig.to_base64();
299        let parsed = Signature::from_base64(&b64).unwrap();
300        assert_eq!(parsed, sig);
301    }
302
303    #[test]
304    fn module_signature_round_trips_through_json() {
305        let signer = signing_key_from_seed(4);
306        let digest = ContentHash::from([0x22; 32]);
307        let module_sig = ModuleSignature {
308            public_key: signer.verifying_key(),
309            signature: signer.sign(&digest),
310        };
311
312        let mut buf = Vec::new();
313        module_sig.write(&mut buf).unwrap();
314        let parsed = ModuleSignature::parse(&buf).unwrap();
315        assert_eq!(parsed, module_sig);
316        parsed.verify(&digest).unwrap();
317    }
318
319    #[test]
320    fn module_signature_rejects_unknown_keys() {
321        let signer = signing_key_from_seed(5);
322        let digest = ContentHash::from([0x33; 32]);
323        let json = format!(
324            r#"{{
325                "public_key": {},
326                "signature": {},
327                "unexpected": true
328            }}"#,
329            serde_json::to_string(&signer.verifying_key()).unwrap(),
330            serde_json::to_string(&signer.sign(&digest)).unwrap()
331        );
332
333        assert!(ModuleSignature::parse(json.as_bytes()).is_err());
334    }
335
336    #[test]
337    fn module_signature_rejects_duplicate_keys() {
338        let signer = signing_key_from_seed(6);
339        let digest = ContentHash::from([0x44; 32]);
340        let json = format!(
341            r#"{{
342                "public_key": {},
343                "public_key": {},
344                "signature": {}
345            }}"#,
346            serde_json::to_string(&signer.verifying_key()).unwrap(),
347            serde_json::to_string(&signer.verifying_key()).unwrap(),
348            serde_json::to_string(&signer.sign(&digest)).unwrap()
349        );
350
351        let err = ModuleSignature::parse(json.as_bytes()).unwrap_err();
352        assert!(
353            err.to_string().contains("invalid `module.sig` JSON"),
354            "wrong error: {err}"
355        );
356    }
357}