1use std::fmt;
22
23use base64::Engine;
24use base64::engine::general_purpose::STANDARD;
25use serde::{Deserialize, Serialize};
26use zeroize::{Zeroize, Zeroizing};
27
28use crate::keys::Ed25519Identity;
29
30pub const SEAT_FILE_VERSION: u32 = 1;
32
33const PKCS8_ED25519_PREFIX: [u8; 16] = [
36 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
37];
38const PRIVATE_KEY_LABEL: &str = "PRIVATE KEY";
40const LINE_WIDTH: usize = 64;
42
43#[derive(Debug, thiserror::Error, PartialEq, Eq)]
46pub enum SeatFileError {
47 #[error("seat_file_unreadable: the seat file is not a seat file's JSON: {reason}")]
49 Unreadable {
50 reason: String,
52 },
53 #[error(
55 "seat_file_version_unknown: the seat file {found}; this lys reads version {}: add \"version\": {} if it is a seat file of that shape",
56 SEAT_FILE_VERSION,
57 SEAT_FILE_VERSION
58 )]
59 VersionUnknown {
60 found: VersionFound,
62 },
63 #[error("seat_file_key_unreadable: {reason}")]
65 KeyUnreadable {
66 reason: &'static str,
68 },
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct VersionFound(pub Option<u64>);
74
75impl fmt::Display for VersionFound {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 match self.0 {
78 Some(version) => write!(f, "is version {version}"),
79 None => f.write_str("carries no version"),
80 }
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct DoorSeat {
87 pub door_url: String,
89 pub handle: String,
91 pub device: String,
93}
94
95#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub struct SeatFile {
99 pub version: u32,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub door_url: Option<String>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub handle: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub device: Option<String>,
110 pub private_key_pem: String,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub lys_url: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub lys_agent_id: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub certificate_pem: Option<String>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub serial: Option<String>,
125}
126
127impl fmt::Debug for SeatFile {
128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 f.debug_struct("SeatFile")
130 .field("version", &self.version)
131 .field("door_url", &self.door_url)
132 .field("handle", &self.handle)
133 .field("device", &self.device)
134 .field("private_key_pem", &"[REDACTED]")
135 .field("lys_url", &self.lys_url)
136 .field("lys_agent_id", &self.lys_agent_id)
137 .field("certificate_pem", &self.certificate_pem)
138 .field("serial", &self.serial)
139 .finish()
140 }
141}
142
143impl Drop for SeatFile {
144 fn drop(&mut self) {
145 self.private_key_pem.zeroize();
146 }
147}
148
149impl SeatFile {
150 #[must_use]
154 pub fn new(
155 seed: &Zeroizing<[u8; 32]>,
156 (lys_url, lys_agent_id): (&str, &str),
157 door: Option<DoorSeat>,
158 ) -> Self {
159 let (door_url, handle, device) = door.map_or((None, None, None), |door| {
160 (Some(door.door_url), Some(door.handle), Some(door.device))
161 });
162 Self {
163 version: SEAT_FILE_VERSION,
164 door_url,
165 handle,
166 device,
167 private_key_pem: private_key_pem(seed).to_string(),
168 lys_url: Some(lys_url.to_owned()),
169 lys_agent_id: Some(lys_agent_id.to_owned()),
170 certificate_pem: None,
171 serial: None,
172 }
173 }
174
175 pub fn from_json(text: &str) -> Result<Self, SeatFileError> {
182 let unreadable = |error: &serde_json::Error| SeatFileError::Unreadable {
184 reason: format!(
185 "line {} column {}: {:?}",
186 error.line(),
187 error.column(),
188 error.classify()
189 ),
190 };
191 let members: serde_json::Value =
192 serde_json::from_str(text).map_err(|error| unreadable(&error))?;
193 let found = members.get("version").map(serde_json::Value::as_u64);
194 match found {
195 Some(Some(version)) if version == u64::from(SEAT_FILE_VERSION) => {}
196 Some(None) => {
197 return Err(SeatFileError::Unreadable {
198 reason: "version is not a whole number".to_owned(),
199 });
200 }
201 other => {
202 return Err(SeatFileError::VersionUnknown {
203 found: VersionFound(other.flatten()),
204 });
205 }
206 }
207 serde_json::from_value(members).map_err(|error| unreadable(&error))
208 }
209
210 pub fn to_json(&self) -> Result<Zeroizing<String>, SeatFileError> {
216 serde_json::to_string_pretty(self)
217 .map(Zeroizing::new)
218 .map_err(|error| SeatFileError::Unreadable {
219 reason: error.to_string(),
220 })
221 }
222
223 #[must_use]
225 pub fn door(&self) -> Option<DoorSeat> {
226 Some(DoorSeat {
227 door_url: self.door_url.clone()?,
228 handle: self.handle.clone()?,
229 device: self.device.clone()?,
230 })
231 }
232
233 pub fn identity(&self) -> Result<Ed25519Identity, SeatFileError> {
239 identity_from_pem(&self.private_key_pem)
240 }
241}
242
243#[must_use]
245pub fn private_key_pem(seed: &Zeroizing<[u8; 32]>) -> Zeroizing<String> {
246 let mut der = Zeroizing::new(Vec::with_capacity(PKCS8_ED25519_PREFIX.len() + seed.len()));
247 der.extend_from_slice(&PKCS8_ED25519_PREFIX);
248 der.extend_from_slice(seed.as_slice());
249 let body = Zeroizing::new(STANDARD.encode(der.as_slice()));
250 let mut pem = Zeroizing::new(String::with_capacity(body.len() + 64));
251 pem.push_str("-----BEGIN ");
252 pem.push_str(PRIVATE_KEY_LABEL);
253 pem.push_str("-----\n");
254 for (index, character) in body.chars().enumerate() {
255 if index > 0 && index % LINE_WIDTH == 0 {
256 pem.push('\n');
257 }
258 pem.push(character);
259 }
260 pem.push_str("\n-----END ");
261 pem.push_str(PRIVATE_KEY_LABEL);
262 pem.push_str("-----\n");
263 pem
264}
265
266pub fn identity_from_pem(pem: &str) -> Result<Ed25519Identity, SeatFileError> {
272 let body: Zeroizing<String> = Zeroizing::new(
273 pem.lines()
274 .map(str::trim)
275 .filter(|line| !line.is_empty() && !line.starts_with("-----"))
276 .collect(),
277 );
278 let der = Zeroizing::new(STANDARD.decode(body.as_bytes()).map_err(|_not_pem| {
279 SeatFileError::KeyUnreadable {
280 reason: "the key is not PEM",
281 }
282 })?);
283 let seed = der
284 .strip_prefix(&PKCS8_ED25519_PREFIX)
285 .and_then(|seed| <[u8; 32]>::try_from(seed).ok())
286 .ok_or(SeatFileError::KeyUnreadable {
287 reason: "the key is not a PKCS#8 ed25519 private key",
288 })?;
289 Ok(Ed25519Identity::from_seed(&Zeroizing::new(seed)))
290}
291
292#[cfg(test)]
293#[path = "seat_file_tests.rs"]
294mod tests;