merge: origin/main (Dev-D keyfile, d649203) into org-foundation
Additive conflict in extension/src/wasm.d.ts resolved by keeping BOTH the org_* declarations (org_unwrap_key/org_manifest_decrypt/org_manifest_encrypt) and the keyfile_* declarations (unlock_with_secret/keyfile_encode/keyfile_decode). Rust lib.rs (core + wasm) auto-merged cleanly with both feature sets. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g
This commit is contained in:
@@ -55,6 +55,29 @@ use zeroize::Zeroizing;
|
||||
|
||||
use crate::error::{RelicarioError, Result};
|
||||
|
||||
/// Which second-factor container a vault uses to supply its 256-bit secret.
|
||||
///
|
||||
/// This is a non-secret hint stored in params.json so that the CLI (and future
|
||||
/// clients) know which unlock branch to follow. It does **not** influence the
|
||||
/// KDF itself; only the container that delivers `image_secret` changes.
|
||||
///
|
||||
/// Serialized as lowercase via `serde(rename_all = "lowercase")`:
|
||||
/// - `"image"` — default; secret embedded in a reference JPEG via DCT stego
|
||||
/// - `"keyfile"` — secret stored in a plain key file (Task 4+)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SecondFactor {
|
||||
/// The secret is embedded in a reference JPEG via DCT steganography.
|
||||
/// This is the original and default mode for every vault created before
|
||||
/// keyfile support was introduced.
|
||||
#[default]
|
||||
Image,
|
||||
/// The secret is stored in a plain key file on disk.
|
||||
/// Vaults with this hint require a key-file path at unlock time instead of
|
||||
/// a reference image.
|
||||
Keyfile,
|
||||
}
|
||||
|
||||
/// Current binary format version. Increment this if the ciphertext layout changes.
|
||||
pub const VERSION_BYTE: u8 = 0x02;
|
||||
|
||||
@@ -417,6 +440,17 @@ mod tests {
|
||||
assert_eq!(VERSION_BYTE, 0x02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_factor_serde_behavior() {
|
||||
assert_eq!(serde_json::to_string(&SecondFactor::Image).unwrap(), "\"image\"");
|
||||
assert_eq!(serde_json::to_string(&SecondFactor::Keyfile).unwrap(), "\"keyfile\"");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<SecondFactor>("\"keyfile\"").unwrap(),
|
||||
SecondFactor::Keyfile
|
||||
);
|
||||
assert_eq!(SecondFactor::default(), SecondFactor::Image);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_rejects_v1_blob_with_typed_error() {
|
||||
// Construct a v1-style blob: [0x01][24 nonce bytes][16 tag bytes].
|
||||
|
||||
87
crates/relicario-core/src/keyfile.rs
Normal file
87
crates/relicario-core/src/keyfile.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
//! 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
|
||||
//! <base64-standard of 32 bytes>
|
||||
//! ```
|
||||
//!
|
||||
//! 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<base64>\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<u8> {
|
||||
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<Zeroizing<[u8; 32]>> {
|
||||
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 = match lines.next() {
|
||||
Some(line) => line.trim(),
|
||||
None => return Err(RelicarioError::Format("key-file missing body line".into())),
|
||||
};
|
||||
let decoded: Zeroizing<Vec<u8>> = Zeroizing::new(
|
||||
STANDARD
|
||||
.decode(b64)
|
||||
.map_err(|_| RelicarioError::Format("key-file body not base64".into()))?,
|
||||
);
|
||||
if decoded.len() != 32 {
|
||||
return Err(RelicarioError::Format("key-file secret must be 32 bytes".into()));
|
||||
}
|
||||
let mut arr = Zeroizing::new([0u8; 32]);
|
||||
arr.copy_from_slice(&decoded);
|
||||
Ok(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
|
||||
}
|
||||
#[test]
|
||||
fn rejects_non_utf8() {
|
||||
assert!(keyfile_decode(b"\xff\xfe\nAAAA\n").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn rejects_missing_body() {
|
||||
assert!(keyfile_decode(b"relicario-keyfile-v1\n").is_err());
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ pub mod error;
|
||||
pub use error::{RelicarioError, Result};
|
||||
|
||||
pub mod crypto;
|
||||
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE};
|
||||
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, SecondFactor, VERSION_BYTE};
|
||||
|
||||
pub mod ids;
|
||||
pub use ids::{AttachmentId, FieldId, ItemId};
|
||||
@@ -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};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user