nixfleet_proto/
host_key.rs

1//! Host SSH key primitives shared by agent enrollment, CP enroll/renew, and
2//! mint_token. Kept pure-rust (no `ssh-key` dep) so the boundary-contract crate
3//! stays lean - the canonical bridge from "OpenSSH host key bytes on disk" to
4//! "rcgen-usable keypair" and bootstrap-token fingerprints.
5
6use base64::Engine;
7use sha2::{Digest, Sha256};
8
9/// Parse a 32-byte ed25519 raw public key from an OpenSSH-format pubkey
10/// line (`"ssh-ed25519 <base64> [comment]"`). Errors when the line isn't
11/// ed25519, the base64 doesn't decode, or the inner SSH-wire-format
12/// (RFC 4253 §6.6: `string "ssh-ed25519" || string <32-byte pubkey>`)
13/// is malformed.
14pub fn ed25519_pubkey_raw_from_openssh(line: &str) -> Result<[u8; 32], OpenSshParseError> {
15    let trimmed = line.trim();
16    let mut parts = trimmed.split_whitespace();
17    let algo = parts.next().ok_or(OpenSshParseError::Empty)?;
18    if algo != "ssh-ed25519" {
19        return Err(OpenSshParseError::WrongAlgorithm {
20            got: algo.to_string(),
21        });
22    }
23    let blob_b64 = parts.next().ok_or(OpenSshParseError::MissingBase64)?;
24    let blob = base64::engine::general_purpose::STANDARD
25        .decode(blob_b64)
26        .map_err(|_| OpenSshParseError::InvalidBase64)?;
27
28    // RFC 4253 §6.6 wire format: u32 big-endian length + bytes per field.
29    let mut cursor = 0usize;
30    let algo_bytes = read_ssh_string(&blob, &mut cursor)?;
31    if algo_bytes != b"ssh-ed25519" {
32        return Err(OpenSshParseError::InnerAlgorithmMismatch);
33    }
34    let pubkey = read_ssh_string(&blob, &mut cursor)?;
35    if pubkey.len() != 32 {
36        return Err(OpenSshParseError::WrongPubkeyLength { got: pubkey.len() });
37    }
38    let mut out = [0u8; 32];
39    out.copy_from_slice(pubkey);
40    Ok(out)
41}
42
43/// Wrap a 32-byte ed25519 seed in PKCS#8 v1 DER (RFC 8410, OID 1.3.101.112).
44/// The 16-byte prefix is the fixed PKCS#8 envelope for an ed25519 seed:
45///   `SEQUENCE(46) { INTEGER(0); SEQUENCE { OID 1.3.101.112 }; OCTET STRING(34) { OCTET STRING(32) <seed> } }`.
46pub fn ed25519_pkcs8_der_from_seed(seed: &[u8; 32]) -> Vec<u8> {
47    let mut out = Vec::with_capacity(48);
48    out.extend_from_slice(&[
49        0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04,
50        0x20,
51    ]);
52    out.extend_from_slice(seed);
53    out
54}
55
56/// PEM-armoured form of [`ed25519_pkcs8_der_from_seed`], for callers
57/// that need to hand a string to `rcgen::KeyPair::from_pem`.
58pub fn ed25519_pkcs8_pem_from_seed(seed: &[u8; 32]) -> String {
59    let der = ed25519_pkcs8_der_from_seed(seed);
60    let b64 = base64::engine::general_purpose::STANDARD.encode(&der);
61    format!("-----BEGIN PRIVATE KEY-----\n{b64}\n-----END PRIVATE KEY-----\n")
62}
63
64/// Extract the 32-byte raw ed25519 pubkey from a SubjectPublicKeyInfo DER
65/// blob (RFC 8410, what `rcgen::PublicKeyData::der_bytes()` returns). Validates
66/// the fixed 12-byte SPKI prefix so callers reject non-ed25519 CSRs cleanly
67/// instead of silently slicing.
68pub fn ed25519_pubkey_raw_from_spki_der(spki: &[u8]) -> Result<[u8; 32], OpenSshParseError> {
69    const ED25519_SPKI_PREFIX: [u8; 12] = [
70        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
71    ];
72    if spki.len() != 44 {
73        return Err(OpenSshParseError::WrongPubkeyLength { got: spki.len() });
74    }
75    if spki[..12] != ED25519_SPKI_PREFIX {
76        return Err(OpenSshParseError::WrongAlgorithm {
77            got: format!(
78                "non-ed25519 SPKI (prefix={:x?})",
79                &spki[..12.min(spki.len())]
80            ),
81        });
82    }
83    let mut out = [0u8; 32];
84    out.copy_from_slice(&spki[12..]);
85    Ok(out)
86}
87
88/// `base64(SHA-256(raw_pubkey))` - same shape as the bootstrap-token's
89/// `expected_pubkey_fingerprint` field. Accepts an OpenSSH pubkey line
90/// directly so callers don't double-parse.
91pub fn fingerprint_openssh_pubkey(line: &str) -> Result<String, OpenSshParseError> {
92    let raw = ed25519_pubkey_raw_from_openssh(line)?;
93    let digest = Sha256::digest(raw);
94    Ok(base64::engine::general_purpose::STANDARD.encode(digest))
95}
96
97fn read_ssh_string<'a>(buf: &'a [u8], cursor: &mut usize) -> Result<&'a [u8], OpenSshParseError> {
98    if *cursor + 4 > buf.len() {
99        return Err(OpenSshParseError::Truncated);
100    }
101    let len = u32::from_be_bytes([
102        buf[*cursor],
103        buf[*cursor + 1],
104        buf[*cursor + 2],
105        buf[*cursor + 3],
106    ]) as usize;
107    *cursor += 4;
108    if *cursor + len > buf.len() {
109        return Err(OpenSshParseError::Truncated);
110    }
111    let s = &buf[*cursor..*cursor + len];
112    *cursor += len;
113    Ok(s)
114}
115
116#[derive(Debug)]
117pub enum OpenSshParseError {
118    Empty,
119    WrongAlgorithm { got: String },
120    MissingBase64,
121    InvalidBase64,
122    Truncated,
123    InnerAlgorithmMismatch,
124    WrongPubkeyLength { got: usize },
125}
126
127impl std::fmt::Display for OpenSshParseError {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        match self {
130            Self::Empty => write!(f, "empty OpenSSH pubkey line"),
131            Self::WrongAlgorithm { got } => {
132                write!(f, "expected ssh-ed25519 algorithm, got {got}")
133            }
134            Self::MissingBase64 => write!(f, "OpenSSH pubkey missing base64 blob"),
135            Self::InvalidBase64 => write!(f, "OpenSSH pubkey base64 invalid"),
136            Self::Truncated => write!(f, "OpenSSH wire blob truncated"),
137            Self::InnerAlgorithmMismatch => {
138                write!(f, "OpenSSH wire blob inner algo != ssh-ed25519")
139            }
140            Self::WrongPubkeyLength { got } => {
141                write!(f, "ed25519 pubkey is not 32 bytes (got {got})")
142            }
143        }
144    }
145}
146
147impl std::error::Error for OpenSshParseError {}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    /// Hand-built fixture: ssh-ed25519 pubkey with all-0x42 raw bytes.
154    fn make_openssh_line(raw: &[u8; 32]) -> String {
155        let mut blob = Vec::new();
156        blob.extend_from_slice(&(b"ssh-ed25519".len() as u32).to_be_bytes());
157        blob.extend_from_slice(b"ssh-ed25519");
158        blob.extend_from_slice(&(raw.len() as u32).to_be_bytes());
159        blob.extend_from_slice(raw);
160        let b64 = base64::engine::general_purpose::STANDARD.encode(&blob);
161        format!("ssh-ed25519 {b64} test@host")
162    }
163
164    #[test]
165    fn round_trips_round_pubkey_bytes() {
166        let raw = [0x42u8; 32];
167        let line = make_openssh_line(&raw);
168        let got = ed25519_pubkey_raw_from_openssh(&line).expect("parse");
169        assert_eq!(got, raw);
170    }
171
172    #[test]
173    fn rejects_non_ed25519_algorithm() {
174        let err = ed25519_pubkey_raw_from_openssh("ssh-rsa AAAA test").unwrap_err();
175        matches!(err, OpenSshParseError::WrongAlgorithm { .. });
176    }
177
178    #[test]
179    fn rejects_missing_blob() {
180        let err = ed25519_pubkey_raw_from_openssh("ssh-ed25519").unwrap_err();
181        matches!(err, OpenSshParseError::MissingBase64);
182    }
183
184    #[test]
185    fn rejects_invalid_base64() {
186        let err = ed25519_pubkey_raw_from_openssh("ssh-ed25519 !!!notbase64!!!").unwrap_err();
187        matches!(err, OpenSshParseError::InvalidBase64);
188    }
189
190    #[test]
191    fn pkcs8_envelope_has_expected_layout() {
192        let seed = [0x55u8; 32];
193        let der = ed25519_pkcs8_der_from_seed(&seed);
194        assert_eq!(der.len(), 48);
195        assert_eq!(
196            &der[..16],
197            &[
198                0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22,
199                0x04, 0x20,
200            ]
201        );
202        assert_eq!(&der[16..], &seed);
203    }
204
205    #[test]
206    fn pkcs8_pem_armours_correctly() {
207        let seed = [0u8; 32];
208        let pem = ed25519_pkcs8_pem_from_seed(&seed);
209        assert!(pem.starts_with("-----BEGIN PRIVATE KEY-----\n"));
210        assert!(pem.trim_end().ends_with("-----END PRIVATE KEY-----"));
211    }
212
213    #[test]
214    fn spki_der_round_trips_raw_bytes() {
215        let raw = [0x77u8; 32];
216        let mut spki = vec![
217            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
218        ];
219        spki.extend_from_slice(&raw);
220        let got = ed25519_pubkey_raw_from_spki_der(&spki).expect("parse SPKI");
221        assert_eq!(got, raw);
222    }
223
224    #[test]
225    fn spki_der_rejects_wrong_length() {
226        let err = ed25519_pubkey_raw_from_spki_der(&[0u8; 43]).unwrap_err();
227        matches!(err, OpenSshParseError::WrongPubkeyLength { .. });
228    }
229
230    #[test]
231    fn spki_der_rejects_non_ed25519_prefix() {
232        let mut spki = vec![0xffu8; 44];
233        spki[0] = 0x30;
234        spki[1] = 0x2a;
235        let err = ed25519_pubkey_raw_from_spki_der(&spki).unwrap_err();
236        matches!(err, OpenSshParseError::WrongAlgorithm { .. });
237    }
238
239    #[test]
240    fn fingerprint_matches_manual_computation() {
241        let raw = [0x42u8; 32];
242        let line = make_openssh_line(&raw);
243        let got = fingerprint_openssh_pubkey(&line).expect("fingerprint");
244        let expected = {
245            let digest = Sha256::digest(raw);
246            base64::engine::general_purpose::STANDARD.encode(digest)
247        };
248        assert_eq!(got, expected);
249    }
250}