use std::fmt;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use serde::{Deserialize, Serialize};
use zeroize::{Zeroize, Zeroizing};
use crate::keys::Ed25519Identity;
pub const SEAT_FILE_VERSION: u32 = 1;
const PKCS8_ED25519_PREFIX: [u8; 16] = [
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
];
const PRIVATE_KEY_LABEL: &str = "PRIVATE KEY";
const LINE_WIDTH: usize = 64;
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum SeatFileError {
#[error("seat_file_unreadable: the seat file is not a seat file's JSON: {reason}")]
Unreadable {
reason: String,
},
#[error(
"seat_file_version_unknown: the seat file {found}; this lys reads version {}: add \"version\": {} if it is a seat file of that shape",
SEAT_FILE_VERSION,
SEAT_FILE_VERSION
)]
VersionUnknown {
found: VersionFound,
},
#[error("seat_file_key_unreadable: {reason}")]
KeyUnreadable {
reason: &'static str,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VersionFound(pub Option<u64>);
impl fmt::Display for VersionFound {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
Some(version) => write!(f, "is version {version}"),
None => f.write_str("carries no version"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DoorSeat {
pub door_url: String,
pub handle: String,
pub device: String,
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SeatFile {
pub version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub door_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handle: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub device: Option<String>,
pub private_key_pem: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lys_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lys_agent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub certificate_pem: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub serial: Option<String>,
}
impl fmt::Debug for SeatFile {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SeatFile")
.field("version", &self.version)
.field("door_url", &self.door_url)
.field("handle", &self.handle)
.field("device", &self.device)
.field("private_key_pem", &"[REDACTED]")
.field("lys_url", &self.lys_url)
.field("lys_agent_id", &self.lys_agent_id)
.field("certificate_pem", &self.certificate_pem)
.field("serial", &self.serial)
.finish()
}
}
impl Drop for SeatFile {
fn drop(&mut self) {
self.private_key_pem.zeroize();
}
}
impl SeatFile {
#[must_use]
pub fn new(
seed: &Zeroizing<[u8; 32]>,
(lys_url, lys_agent_id): (&str, &str),
door: Option<DoorSeat>,
) -> Self {
let (door_url, handle, device) = door.map_or((None, None, None), |door| {
(Some(door.door_url), Some(door.handle), Some(door.device))
});
Self {
version: SEAT_FILE_VERSION,
door_url,
handle,
device,
private_key_pem: private_key_pem(seed).to_string(),
lys_url: Some(lys_url.to_owned()),
lys_agent_id: Some(lys_agent_id.to_owned()),
certificate_pem: None,
serial: None,
}
}
pub fn from_json(text: &str) -> Result<Self, SeatFileError> {
let unreadable = |error: &serde_json::Error| SeatFileError::Unreadable {
reason: format!(
"line {} column {}: {:?}",
error.line(),
error.column(),
error.classify()
),
};
let members: serde_json::Value =
serde_json::from_str(text).map_err(|error| unreadable(&error))?;
let found = members.get("version").map(serde_json::Value::as_u64);
match found {
Some(Some(version)) if version == u64::from(SEAT_FILE_VERSION) => {}
Some(None) => {
return Err(SeatFileError::Unreadable {
reason: "version is not a whole number".to_owned(),
});
}
other => {
return Err(SeatFileError::VersionUnknown {
found: VersionFound(other.flatten()),
});
}
}
serde_json::from_value(members).map_err(|error| unreadable(&error))
}
pub fn to_json(&self) -> Result<Zeroizing<String>, SeatFileError> {
serde_json::to_string_pretty(self)
.map(Zeroizing::new)
.map_err(|error| SeatFileError::Unreadable {
reason: error.to_string(),
})
}
#[must_use]
pub fn door(&self) -> Option<DoorSeat> {
Some(DoorSeat {
door_url: self.door_url.clone()?,
handle: self.handle.clone()?,
device: self.device.clone()?,
})
}
pub fn identity(&self) -> Result<Ed25519Identity, SeatFileError> {
identity_from_pem(&self.private_key_pem)
}
}
#[must_use]
pub fn private_key_pem(seed: &Zeroizing<[u8; 32]>) -> Zeroizing<String> {
let mut der = Zeroizing::new(Vec::with_capacity(PKCS8_ED25519_PREFIX.len() + seed.len()));
der.extend_from_slice(&PKCS8_ED25519_PREFIX);
der.extend_from_slice(seed.as_slice());
let body = Zeroizing::new(STANDARD.encode(der.as_slice()));
let mut pem = Zeroizing::new(String::with_capacity(body.len() + 64));
pem.push_str("-----BEGIN ");
pem.push_str(PRIVATE_KEY_LABEL);
pem.push_str("-----\n");
for (index, character) in body.chars().enumerate() {
if index > 0 && index % LINE_WIDTH == 0 {
pem.push('\n');
}
pem.push(character);
}
pem.push_str("\n-----END ");
pem.push_str(PRIVATE_KEY_LABEL);
pem.push_str("-----\n");
pem
}
pub fn identity_from_pem(pem: &str) -> Result<Ed25519Identity, SeatFileError> {
let body: Zeroizing<String> = Zeroizing::new(
pem.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with("-----"))
.collect(),
);
let der = Zeroizing::new(STANDARD.decode(body.as_bytes()).map_err(|_not_pem| {
SeatFileError::KeyUnreadable {
reason: "the key is not PEM",
}
})?);
let seed = der
.strip_prefix(&PKCS8_ED25519_PREFIX)
.and_then(|seed| <[u8; 32]>::try_from(seed).ok())
.ok_or(SeatFileError::KeyUnreadable {
reason: "the key is not a PKCS#8 ed25519 private key",
})?;
Ok(Ed25519Identity::from_seed(&Zeroizing::new(seed)))
}
#[cfg(test)]
#[path = "seat_file_tests.rs"]
mod tests;