libsignify_rs/
file.rs

1//
2// signify-rs: cryptographically sign and verify files
3// src/file.rs: File utility functions
4//
5// Copyright (c) 2025, 2026 Ali Polatel <alip@chesswob.org>
6// Based in part upon OpenBSD's signify which is:
7//   Copyright (c) 2013 Ted Unangst <tedu@openbsd.org>
8//   Copyright (c) 2016 Marc Espie <espie@openbsd.org>
9//   Copyright (c) 2019 Adrian Perez de Castro <aperez@igalia.com>
10//   Copyright (c) 2019 Scott Bennett and other contributors
11//   SPDX-License-Identifier: ISC
12//
13// SPDX-License-Identifier: ISC
14
15use crate::crypto::{CHECKSUM_LEN, KDFALG, KEYNUMLEN, PKALG, SALT_LEN};
16use crate::error::{Error, Result};
17use base64ct::{Base64, Encoding as _};
18use core::str;
19use memchr::memchr;
20use static_assertions::assert_eq_size;
21use std::fs::OpenOptions;
22use std::io::stdin;
23use std::io::{self, Read, Write};
24use std::path::Path;
25use zeroize::Zeroize;
26
27/// Untrusted comment header.
28pub const COMMENTHDR: &str = "untrusted comment: ";
29/// Max comment length.
30pub const MAX_COMMENT_LEN: usize = 1024;
31
32/// Encrypted secret key structure.
33///
34/// Stores the encrypted secret key along with KDF parameters and checksums.
35#[repr(C)]
36#[derive(Debug)]
37pub struct EncKey {
38    /// Public key algorithm (must be "Ed").
39    pub pkalg: [u8; 2],
40    /// KDF algorithm (must be "BK").
41    pub kdfalg: [u8; 2],
42    /// Number of KDF rounds.
43    pub kdfrounds: u32,
44    /// Salt for KDF.
45    pub salt: [u8; SALT_LEN],
46    /// Checksum of the decrypted key.
47    pub checksum: [u8; CHECKSUM_LEN],
48    /// Key ID (`KeyNum`).
49    pub keynum: [u8; KEYNUMLEN],
50    /// Encrypted secret key data.
51    pub seckey: [u8; 64],
52}
53
54impl Zeroize for EncKey {
55    fn zeroize(&mut self) {
56        self.pkalg.zeroize();
57        self.kdfalg.zeroize();
58        self.kdfrounds.zeroize();
59        self.salt.zeroize();
60        self.checksum.zeroize();
61        self.keynum.zeroize();
62        self.seckey.zeroize();
63    }
64}
65
66impl Drop for EncKey {
67    fn drop(&mut self) {
68        self.zeroize();
69    }
70}
71
72/// Public key structure.
73#[repr(C)]
74#[derive(Debug, Clone, Copy)]
75pub struct PubKey {
76    /// Public key algorithm (must be "Ed").
77    pub pkalg: [u8; 2],
78    /// Key ID (`KeyNum`).
79    pub keynum: [u8; KEYNUMLEN],
80    /// Public key data.
81    pub pubkey: [u8; 32],
82}
83
84/// Signature structure.
85#[derive(Debug, Clone, Copy)]
86pub struct Sig {
87    /// Public key algorithm (must be "Ed").
88    pub pkalg: [u8; 2],
89    /// Key ID (`KeyNum`).
90    pub keynum: [u8; KEYNUMLEN],
91    /// Signature data.
92    pub sig: [u8; 64],
93}
94
95impl EncKey {
96    /// Parse `EncKey` from raw bytes.
97    ///
98    /// # Errors
99    ///
100    /// Returns `Error::InvalidKeyLength` if byte length is incorrect.
101    /// Returns `Error::UnsupportedPkAlgo` if algorithm is not Ed.
102    /// Returns `Error::UnsupportedKdfAlgo` if algorithm is not BK.
103    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
104        let pkalg = bytes
105            .get(0..2)
106            .ok_or(Error::InvalidKeyLength)?
107            .try_into()
108            .map_err(|_e| Error::InvalidKeyLength)?;
109        let kdfalg = bytes
110            .get(2..4)
111            .ok_or(Error::InvalidKeyLength)?
112            .try_into()
113            .map_err(|_e| Error::InvalidKeyLength)?;
114        let kdfrounds_bytes = bytes.get(4..8).ok_or(Error::InvalidKeyLength)?;
115        let kdfrounds = u32::from_be_bytes(
116            kdfrounds_bytes
117                .try_into()
118                .map_err(|_e| Error::InvalidKeyLength)?,
119        );
120        let salt = bytes
121            .get(8..24)
122            .ok_or(Error::InvalidKeyLength)?
123            .try_into()
124            .map_err(|_e| Error::InvalidKeyLength)?;
125        let checksum = bytes
126            .get(24..32)
127            .ok_or(Error::InvalidKeyLength)?
128            .try_into()
129            .map_err(|_e| Error::InvalidKeyLength)?;
130        let keynum = bytes
131            .get(32..40)
132            .ok_or(Error::InvalidKeyLength)?
133            .try_into()
134            .map_err(|_e| Error::InvalidKeyLength)?;
135        let seckey = bytes
136            .get(40..104)
137            .ok_or(Error::InvalidKeyLength)?
138            .try_into()
139            .map_err(|_e| Error::InvalidKeyLength)?;
140
141        // Strict length check
142        if bytes.len() != 104 {
143            return Err(Error::InvalidKeyLength);
144        }
145
146        if pkalg != PKALG {
147            return Err(Error::UnsupportedPkAlgo);
148        }
149        if kdfalg != KDFALG {
150            return Err(Error::UnsupportedKdfAlgo);
151        }
152
153        Ok(Self {
154            pkalg,
155            kdfalg,
156            kdfrounds,
157            salt,
158            checksum,
159            keynum,
160            seckey,
161        })
162    }
163
164    /// Serialize `EncKey` to raw bytes.
165    #[must_use]
166    pub fn to_bytes(&self) -> Vec<u8> {
167        let mut out = Vec::with_capacity(104);
168        out.extend_from_slice(&self.pkalg);
169        out.extend_from_slice(&self.kdfalg);
170        out.extend_from_slice(&self.kdfrounds.to_be_bytes());
171        out.extend_from_slice(&self.salt);
172        out.extend_from_slice(&self.checksum);
173        out.extend_from_slice(&self.keynum);
174        out.extend_from_slice(&self.seckey);
175        out
176    }
177}
178
179impl PubKey {
180    /// Parse `PubKey` from bytes.
181    ///
182    /// # Errors
183    ///
184    /// Returns `Error::InvalidKeyLength` if byte length is incorrect.
185    /// Returns `Error::UnsupportedPkAlgo` if algorithm is not Ed.
186    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
187        let pkalg = bytes
188            .get(0..2)
189            .ok_or(Error::InvalidKeyLength)?
190            .try_into()
191            .map_err(|_e| Error::InvalidKeyLength)?;
192        let keynum = bytes
193            .get(2..10)
194            .ok_or(Error::InvalidKeyLength)?
195            .try_into()
196            .map_err(|_e| Error::InvalidKeyLength)?;
197        let pubkey = bytes
198            .get(10..42)
199            .ok_or(Error::InvalidKeyLength)?
200            .try_into()
201            .map_err(|_e| Error::InvalidKeyLength)?;
202
203        if bytes.len() != 42 {
204            return Err(Error::InvalidKeyLength);
205        }
206
207        if pkalg != PKALG {
208            return Err(Error::UnsupportedPkAlgo);
209        }
210
211        Ok(Self {
212            pkalg,
213            keynum,
214            pubkey,
215        })
216    }
217
218    /// Serialize `PubKey` to bytes.
219    #[must_use]
220    pub fn to_bytes(&self) -> Vec<u8> {
221        let mut out = Vec::with_capacity(42);
222        out.extend_from_slice(&self.pkalg);
223        out.extend_from_slice(&self.keynum);
224        out.extend_from_slice(&self.pubkey);
225        out
226    }
227}
228
229impl Sig {
230    /// Parse `Sig` from bytes.
231    ///
232    /// # Errors
233    ///
234    /// Returns `Error::InvalidKeyLength` if byte length is incorrect.
235    /// Returns `Error::UnsupportedPkAlgo` if algorithm is not Ed.
236    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
237        let pkalg = bytes
238            .get(0..2)
239            .ok_or(Error::InvalidKeyLength)?
240            .try_into()
241            .map_err(|_e| Error::InvalidKeyLength)?;
242        let keynum = bytes
243            .get(2..10)
244            .ok_or(Error::InvalidKeyLength)?
245            .try_into()
246            .map_err(|_e| Error::InvalidKeyLength)?;
247        let sig = bytes
248            .get(10..74)
249            .ok_or(Error::InvalidKeyLength)?
250            .try_into()
251            .map_err(|_e| Error::InvalidKeyLength)?;
252
253        if bytes.len() != 74 {
254            return Err(Error::InvalidKeyLength);
255        }
256
257        if pkalg != PKALG {
258            return Err(Error::UnsupportedPkAlgo);
259        }
260
261        Ok(Self { pkalg, keynum, sig })
262    }
263
264    /// Serialize `Sig` to bytes.
265    #[must_use]
266    pub fn to_bytes(&self) -> Vec<u8> {
267        let mut out = Vec::with_capacity(74);
268        out.extend_from_slice(&self.pkalg);
269        out.extend_from_slice(&self.keynum);
270        out.extend_from_slice(&self.sig);
271        out
272    }
273}
274
275/// Parse a signify file.
276///
277/// Reads file at `path` (or stdin if "-"), parses the OpenBSD-compatible format
278/// (comment line + base64 line), and decodes the object using `parse_fn`.
279///
280/// # Errors
281///
282/// Returns `Error::Io` if file cannot be read.
283/// Returns `Error::InvalidCommentHeader` if header is missing or malformed.
284/// Returns `Error::InvalidSignatureUtf8` if base64 is invalid utf8.
285/// Returns `Error::Base64Decode` if decoding fails.
286/// Returns various errors from `parse_fn`.
287pub fn parse<T, F>(path: &Path, parse_fn: F) -> Result<(T, Vec<u8>)>
288where
289    F: Fn(&[u8]) -> Result<T>,
290{
291    // Bounded read for header (comment + base64).
292    // Keys are small (104 bytes encoded ~ 140 bytes + comment).
293    // Signatures are small (74 bytes encoded ~ 100 bytes + comment).
294    // 4KB is generous.
295    const HEADER_LIMIT: usize = 4096;
296    let mut header_buf = vec![0_u8; HEADER_LIMIT];
297
298    let mut reader: Box<dyn Read> = if path.to_str() == Some("-") {
299        Box::new(stdin())
300    } else {
301        let file = OpenOptions::new()
302            .read(true)
303            .open(path)
304            .map_err(Error::Io)?;
305        Box::new(file)
306    };
307
308    // We read up to HEADER_LIMIT.
309    let mut total_read = 0;
310    while total_read < HEADER_LIMIT {
311        let n = reader
312            .read(&mut header_buf[total_read..])
313            .map_err(Error::Io)?;
314        if n == 0 {
315            break;
316        }
317        total_read = total_read.checked_add(n).ok_or(Error::Overflow)?;
318    }
319    header_buf.truncate(total_read);
320
321    // Find first newline.
322    let n1 = memchr(b'\n', &header_buf).ok_or(Error::InvalidCommentHeader)?;
323
324    let header_bytes = &header_buf[..n1];
325
326    // Strict verify prefix.
327    let prefix = COMMENTHDR.as_bytes();
328    if !header_bytes.starts_with(prefix) {
329        return Err(Error::InvalidCommentHeader);
330    }
331    let comment = header_bytes[prefix.len()..].to_vec();
332
333    let n2_start = n1.checked_add(1).ok_or(Error::Overflow)?;
334    let n2 = memchr(b'\n', &header_buf[n2_start..])
335        .unwrap_or_else(|| header_buf.len().saturating_sub(n2_start));
336
337    let b64_start = n2_start;
338    let b64_end = b64_start.checked_add(n2).ok_or(Error::Overflow)?;
339
340    if b64_end > header_buf.len() {
341        return Err(Error::InvalidCommentHeader);
342    }
343
344    let b64_bytes = &header_buf[b64_start..b64_end];
345
346    // Base64 decode.
347    let b64_str = str::from_utf8(b64_bytes).map_err(|_e| Error::InvalidSignatureUtf8)?;
348    let decoded = Base64::decode_vec(b64_str.trim()).map_err(Error::Base64Decode)?;
349
350    let obj = parse_fn(&decoded)?;
351    Ok((obj, comment))
352}
353
354/// Write a signify file.
355///
356/// Writes `comment` and base64-encoded `data` to `path` (or stdout if "-").
357///
358/// # Errors
359///
360/// Returns `Error::Io` on file creation or write failure.
361pub fn write(path: &Path, comment: &[u8], data: &[u8]) -> Result<()> {
362    let encoded = Base64::encode_string(data);
363
364    let mut content = Vec::new();
365    content.extend_from_slice(COMMENTHDR.as_bytes());
366    content.extend_from_slice(comment);
367    content.push(b'\n');
368    content.extend_from_slice(encoded.as_bytes());
369    content.push(b'\n');
370
371    let mut writer: Box<dyn Write> = if path.to_str() == Some("-") {
372        Box::new(io::stdout())
373    } else {
374        let mut options = OpenOptions::new();
375        options.write(true).create_new(true);
376        let file = options.open(path).map_err(Error::Io)?;
377        Box::new(file)
378    };
379
380    writer.write_all(&content).map_err(Error::Io)?;
381    Ok(())
382}
383
384assert_eq_size!(EncKey, [u8; 104]);
385assert_eq_size!(PubKey, [u8; 42]);
386assert_eq_size!(Sig, [u8; 74]);
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn test_enckey_serialization() -> crate::error::Result<()> {
394        let enc = EncKey {
395            pkalg: PKALG,
396            kdfalg: KDFALG,
397            kdfrounds: 42,
398            salt: [1u8; SALT_LEN],
399            checksum: [2u8; CHECKSUM_LEN],
400            keynum: [3u8; KEYNUMLEN],
401            seckey: [4u8; 64],
402        };
403        let bytes = enc.to_bytes();
404        let enc2 = EncKey::from_bytes(&bytes)?;
405        assert_eq!(enc.kdfrounds, enc2.kdfrounds);
406        assert_eq!(enc.salt, enc2.salt);
407
408        // Test invalid lengths.
409        assert!(matches!(
410            EncKey::from_bytes(&bytes[..103]),
411            Err(Error::InvalidKeyLength)
412        ));
413
414        let mut long = bytes.clone();
415        long.push(0);
416        assert!(matches!(
417            EncKey::from_bytes(&long),
418            Err(Error::InvalidKeyLength)
419        ));
420
421        // Test bad PKALG.
422        let mut bad_alg = bytes.clone();
423        bad_alg[0] = b'X';
424        assert!(matches!(
425            EncKey::from_bytes(&bad_alg),
426            Err(Error::UnsupportedPkAlgo)
427        ));
428
429        // Test bad KDFALG.
430        let mut bad_kdf = bytes.clone();
431        bad_kdf[2] = b'X';
432        assert!(matches!(
433            EncKey::from_bytes(&bad_kdf),
434            Err(Error::UnsupportedKdfAlgo)
435        ));
436
437        Ok(())
438    }
439
440    #[test]
441    fn test_pubkey_serialization() -> crate::error::Result<()> {
442        let pubk = PubKey {
443            pkalg: PKALG,
444            keynum: [1u8; KEYNUMLEN],
445            pubkey: [2u8; 32],
446        };
447        let bytes = pubk.to_bytes();
448        let pubk2 = PubKey::from_bytes(&bytes)?;
449        assert_eq!(pubk.keynum, pubk2.keynum);
450
451        // Test invalid lengths.
452        assert!(matches!(
453            PubKey::from_bytes(&bytes[..41]),
454            Err(Error::InvalidKeyLength)
455        ));
456        let mut long = bytes.clone();
457        long.push(0);
458        assert!(matches!(
459            PubKey::from_bytes(&long),
460            Err(Error::InvalidKeyLength)
461        ));
462
463        // Test bad PKALG.
464        let mut bad_alg = bytes.clone();
465        bad_alg[0] = b'X';
466        assert!(matches!(
467            PubKey::from_bytes(&bad_alg),
468            Err(Error::UnsupportedPkAlgo)
469        ));
470
471        Ok(())
472    }
473
474    #[test]
475    fn test_sig_serialization() -> crate::error::Result<()> {
476        let sig = Sig {
477            pkalg: PKALG,
478            keynum: [1u8; KEYNUMLEN],
479            sig: [0u8; 64],
480        };
481        let bytes = sig.to_bytes();
482        let sig2 = Sig::from_bytes(&bytes)?;
483        assert_eq!(sig.keynum, sig2.keynum);
484
485        // Test invalid lengths.
486        assert!(matches!(
487            Sig::from_bytes(&bytes[..73]),
488            Err(Error::InvalidKeyLength)
489        ));
490
491        let mut long = bytes.clone();
492        long.push(0);
493        assert!(matches!(
494            Sig::from_bytes(&long),
495            Err(Error::InvalidKeyLength)
496        ));
497
498        // Test bad PKALG.
499        let mut bad_alg = bytes.clone();
500        bad_alg[0] = b'X';
501        assert!(matches!(
502            Sig::from_bytes(&bad_alg),
503            Err(Error::UnsupportedPkAlgo)
504        ));
505
506        Ok(())
507    }
508
509    #[test]
510    fn test_file_io() -> std::result::Result<(), Box<dyn std::error::Error>> {
511        let dir = tempfile::tempdir()?;
512        let path = dir.path().join("secret.key");
513        let data = b"secret data";
514
515        write(&path, b"mycomment", data)?;
516
517        let (read_data, comment) = parse::<Vec<u8>, _>(&path, |b| Ok(b.to_vec()))?;
518        assert_eq!(read_data, data);
519        assert_eq!(comment, b"mycomment");
520
521        // Test missing file.
522        let missing = dir.path().join("missing");
523        assert!(matches!(
524            parse::<Vec<u8>, _>(&missing, |_| Ok(vec![])),
525            Err(Error::Io(_))
526        ));
527
528        // Test invalid prefix.
529        let bad_prefix = dir.path().join("bad_prefix");
530        let mut f = OpenOptions::new()
531            .write(true)
532            .create_new(true)
533            .open(&bad_prefix)?;
534        f.write_all(b"invalid header\n")?;
535        assert!(matches!(
536            parse::<Vec<u8>, _>(&bad_prefix, |_| Ok(vec![])),
537            Err(Error::InvalidCommentHeader)
538        ));
539
540        // Test missing newline.
541        let no_newline = dir.path().join("no_newline");
542        let mut f = OpenOptions::new()
543            .write(true)
544            .create_new(true)
545            .open(&no_newline)?;
546        f.write_all(b"untrusted comment: foo")?;
547        assert!(matches!(
548            parse::<Vec<u8>, _>(&no_newline, |_| Ok(vec![])),
549            Err(Error::InvalidCommentHeader)
550        ));
551
552        // Test invalid base64 (not utf8).
553        // Create valid structure first.
554        // Overwrite base64 part with garbage.
555        let bad_utf8 = dir.path().join("bad_utf8");
556        write(&bad_utf8, b"comment", b"")?;
557        let mut f = OpenOptions::new().write(true).open(&bad_utf8)?;
558        f.write_all(b"untrusted comment: comment\n\xFF\xFF\n")?;
559        assert!(matches!(
560            parse::<Vec<u8>, _>(&bad_utf8, |_| Ok(vec![])),
561            Err(Error::InvalidSignatureUtf8)
562        ));
563
564        Ok(())
565    }
566}