Skip to main content

lys_core/
seat_file.rs

1//! The seat file: one JSON file, mode 0600, that holds an agent's private key
2//! where the agent runs, with what certifies it (AGENTS-017 R5).
3//!
4//! One shape is read by every reader: Lys's `lys mcp` bridge, the
5//! `lys agent enrol` command that writes it, and an app's door, whose
6//! `SeatFile` names the same members (`version`, `doorUrl`, `handle`, `device`,
7//! `privateKeyPem`, `lysUrl`, `lysAgentId`). The member spellings are that
8//! reader's, so a rename here is a seat that will not open. Lys adds
9//! `certificatePem` and `serial`; a reader that does not know them ignores them.
10//!
11//! Invariants:
12//! - `version` is [`SEAT_FILE_VERSION`]; a file of any other version, or of
13//!   none, is refused naming what it carries and the version wanted, never read
14//!   as if it were this one.
15//! - `privateKeyPem` is a PKCS#8 PEM of one Ed25519 seed and nothing else; the
16//!   key never appears in `Debug` output or in an error, and the text is wiped
17//!   from memory when the value is dropped.
18//! - The door's members (`doorUrl`, `handle`, `device`) are written only when
19//!   all three are known, and are read as absent when a Lys-only seat omits them.
20
21use 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
30/// The seat file shape this reader and writer speak, the door's version 1.
31pub const SEAT_FILE_VERSION: u32 = 1;
32
33/// PKCS#8 v1 DER for an Ed25519 private key, before its 32-byte seed
34/// (RFC 8410 section 7).
35const PKCS8_ED25519_PREFIX: [u8; 16] = [
36    0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
37];
38/// The PEM label of a PKCS#8 private key (RFC 7468 section 10).
39const PRIVATE_KEY_LABEL: &str = "PRIVATE KEY";
40/// RFC 7468 recommends wrapping base64 at 64 characters per line.
41const LINE_WIDTH: usize = 64;
42
43/// Why a seat file or its key could not be read or written. No variant carries
44/// key material.
45#[derive(Debug, thiserror::Error, PartialEq, Eq)]
46pub enum SeatFileError {
47    /// The file is not the JSON of a seat file.
48    #[error("seat_file_unreadable: the seat file is not a seat file's JSON: {reason}")]
49    Unreadable {
50        /// What the JSON reader said, without the file's text.
51        reason: String,
52    },
53    /// The file carries a version this reader does not speak, or none.
54    #[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        /// The version the file carries, or that it carries none.
61        found: VersionFound,
62    },
63    /// The private key is not a PKCS#8 PEM of an Ed25519 seed.
64    #[error("seat_file_key_unreadable: {reason}")]
65    KeyUnreadable {
66        /// Which part of the key could not be read.
67        reason: &'static str,
68    },
69}
70
71/// The version a seat file carries, as a refusal says it.
72#[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/// The members of a seat file a door names: all three, or none.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct DoorSeat {
87    /// The base URL of the app's door the seat enrolled at.
88    pub door_url: String,
89    /// The registry handle the seat answers as.
90    pub handle: String,
91    /// The device label carried on the seat's session.
92    pub device: String,
93}
94
95/// A seat file, as written and as read.
96#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub struct SeatFile {
99    /// Always [`SEAT_FILE_VERSION`] once read.
100    pub version: u32,
101    /// The door's base URL, absent for a seat no door founded.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub door_url: Option<String>,
104    /// The registry handle, absent for a seat no door founded.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub handle: Option<String>,
107    /// The device label, absent for a seat no door founded.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub device: Option<String>,
110    /// The agent's Ed25519 private key, PKCS#8 PEM. Never printed.
111    pub private_key_pem: String,
112    /// The Lys API that certified the key, absent in a file written before it.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub lys_url: Option<String>,
115    /// The Lys agent the key's certificate names, absent in a file written
116    /// before it.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub lys_agent_id: Option<String>,
119    /// The certificate Lys issued over the key, PEM, when Lys issued one.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub certificate_pem: Option<String>,
122    /// The certificate's serial in Lys's certificate log.
123    #[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    /// A seat file holding `seed` as its key, for the agent `lys_agent_id`
151    /// certified by the Lys at `lys_url`, with the door's members when a door
152    /// is named.
153    #[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    /// Read a seat file from its JSON text.
176    ///
177    /// # Errors
178    /// [`SeatFileError::Unreadable`] when the text is not a seat file's JSON,
179    /// [`SeatFileError::VersionUnknown`] naming the version found (or none)
180    /// when it is not [`SEAT_FILE_VERSION`].
181    pub fn from_json(text: &str) -> Result<Self, SeatFileError> {
182        // The reader's position and kind, never the text it read: the text holds the key.
183        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    /// The file's JSON text, the key inside it, wiped when dropped.
211    ///
212    /// # Errors
213    /// [`SeatFileError::Unreadable`] when the value cannot be written as JSON,
214    /// which a seat file's plain members never cause.
215    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    /// The door's members, when the file names all three.
224    #[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    /// The identity the file's key is.
234    ///
235    /// # Errors
236    /// [`SeatFileError::KeyUnreadable`] when `privateKeyPem` is not a PKCS#8
237    /// PEM of an Ed25519 seed.
238    pub fn identity(&self) -> Result<Ed25519Identity, SeatFileError> {
239        identity_from_pem(&self.private_key_pem)
240    }
241}
242
243/// `seed` as a PKCS#8 PEM `PRIVATE KEY`, wiped when dropped.
244#[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
266/// The identity a PKCS#8 PEM of an Ed25519 seed holds.
267///
268/// # Errors
269/// [`SeatFileError::KeyUnreadable`] when the text is not PEM, or the PEM is
270/// not a PKCS#8 Ed25519 private key.
271pub 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;