From 3b7099c6f435fd2a664c819a4daf650f2eaacae9 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 21:09:40 -0400 Subject: [PATCH] feat(core): key-file armor (relicario-keyfile-v1) encode/decode Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG --- crates/relicario-core/src/keyfile.rs | 73 ++++++++++++++++++++++++++++ crates/relicario-core/src/lib.rs | 2 + 2 files changed, 75 insertions(+) create mode 100644 crates/relicario-core/src/keyfile.rs diff --git a/crates/relicario-core/src/keyfile.rs b/crates/relicario-core/src/keyfile.rs new file mode 100644 index 0000000..c8a0509 --- /dev/null +++ b/crates/relicario-core/src/keyfile.rs @@ -0,0 +1,73 @@ +//! Key-file armor for the pluggable second factor. The file holds the raw +//! 32-byte secret (base64) behind a version header -- it is the "something +//! you have", not an encrypted artifact (the passphrase is the other factor). +//! +//! The format is intentionally minimal and human-inspectable: +//! +//! ```text +//! relicario-keyfile-v1 +//! +//! ``` +//! +//! Two lines, both terminated with `\n`. There is no encryption here: the file +//! is as sensitive as the raw secret itself. Store it accordingly (encrypted +//! drive, hardware token, offline backup). The passphrase remains the second +//! factor; together they feed Argon2id to derive the master key. + +use base64::{engine::general_purpose::STANDARD, Engine}; +use zeroize::Zeroizing; + +use crate::error::{RelicarioError, Result}; + +const HEADER: &str = "relicario-keyfile-v1"; + +/// Encode a 32-byte secret into the `relicario-keyfile-v1` armor format. +/// +/// Output is: `"relicario-keyfile-v1\n\n"` as UTF-8 bytes. +/// The returned bytes are suitable for writing directly to a `.relkey` file. +pub fn keyfile_encode(secret: &[u8; 32]) -> Vec { + format!("{HEADER}\n{}\n", STANDARD.encode(secret)).into_bytes() +} + +/// Decode a `relicario-keyfile-v1` armored file back to the raw 32-byte secret. +/// +/// Returns [`RelicarioError::Format`] if the header is missing or wrong, if the +/// body is not valid base64, or if the decoded length is not exactly 32 bytes. +pub fn keyfile_decode(bytes: &[u8]) -> Result> { + let text = std::str::from_utf8(bytes) + .map_err(|_| RelicarioError::Format("key file is not UTF-8".into()))?; + let mut lines = text.lines(); + if lines.next() != Some(HEADER) { + return Err(RelicarioError::Format("bad key-file header".into())); + } + let b64 = lines.next().unwrap_or("").trim(); + let decoded = STANDARD + .decode(b64) + .map_err(|_| RelicarioError::Format("key-file body not base64".into()))?; + let arr: [u8; 32] = decoded + .as_slice() + .try_into() + .map_err(|_| RelicarioError::Format("key-file secret must be 32 bytes".into()))?; + Ok(Zeroizing::new(arr)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn round_trip() { + let secret = [9u8; 32]; + let armored = keyfile_encode(&secret); + assert!(std::str::from_utf8(&armored).unwrap().starts_with("relicario-keyfile-v1\n")); + let back = keyfile_decode(&armored).unwrap(); + assert_eq!(*back, secret); + } + #[test] + fn rejects_bad_header() { + assert!(keyfile_decode(b"not-a-keyfile\nAAAA\n").is_err()); + } + #[test] + fn rejects_wrong_length() { + assert!(keyfile_decode(b"relicario-keyfile-v1\nAAAA\n").is_err()); // decodes to <32 bytes + } +} diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index 353ebbc..f362ec2 100644 --- a/crates/relicario-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -84,6 +84,8 @@ pub use vault::{ pub mod imgsecret; +pub mod keyfile; + pub mod backup; pub use backup::{pack_backup, unpack_backup, BackupInput, BackupOutput, BackupItem, BackupAttachment};