feat(cli/org): UnlockedOrgVault session (collection-scoped item_path, fingerprint match, signed org_git_run)
This commit is contained in:
@@ -9,6 +9,7 @@ mod helpers;
|
|||||||
mod parse;
|
mod parse;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
mod session;
|
mod session;
|
||||||
|
mod org_session;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
|||||||
320
crates/relicario-cli/src/org_session.rs
Normal file
320
crates/relicario-cli/src/org_session.rs
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
//! Unlocked org vault session: holds the org master key for the duration of a
|
||||||
|
//! CLI invocation.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use relicario_core::{
|
||||||
|
decrypt_item, decrypt_org_manifest, encrypt_item, encrypt_org_manifest,
|
||||||
|
Item, ItemId, MemberId, OrgCollections, OrgManifest, OrgMember, OrgMembers, OrgMeta,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct UnlockedOrgVault {
|
||||||
|
pub root: PathBuf,
|
||||||
|
pub org_key: Zeroizing<[u8; 32]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UnlockedOrgVault {
|
||||||
|
pub fn root(&self) -> &Path { &self.root }
|
||||||
|
pub fn key(&self) -> &Zeroizing<[u8; 32]> { &self.org_key }
|
||||||
|
|
||||||
|
pub fn manifest_path(&self) -> PathBuf { self.root.join("manifest.enc") }
|
||||||
|
|
||||||
|
/// Collection-scoped item path: `items/<collection-slug>/<id>.enc`.
|
||||||
|
/// The leading slug segment is what the pre-receive hook authorizes against
|
||||||
|
/// members.json — it never decrypts the blob. The slug must be non-empty and
|
||||||
|
/// already validated.
|
||||||
|
pub fn item_path(&self, collection_slug: &str, id: &ItemId) -> PathBuf {
|
||||||
|
self.root
|
||||||
|
.join("items")
|
||||||
|
.join(collection_slug)
|
||||||
|
.join(format!("{}.enc", id.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn member_key_path(&self, id: &MemberId) -> PathBuf {
|
||||||
|
self.root.join("keys").join(format!("{}.enc", id.as_str()))
|
||||||
|
}
|
||||||
|
pub fn members_path(&self) -> PathBuf { self.root.join("members.json") }
|
||||||
|
pub fn collections_path(&self) -> PathBuf { self.root.join("collections.json") }
|
||||||
|
pub fn org_meta_path(&self) -> PathBuf { self.root.join("org.json") }
|
||||||
|
|
||||||
|
pub fn load_meta(&self) -> Result<OrgMeta> {
|
||||||
|
let s = fs::read_to_string(self.org_meta_path()).context("read org.json")?;
|
||||||
|
Ok(serde_json::from_str(&s).context("parse org.json")?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_members(&self) -> Result<OrgMembers> {
|
||||||
|
let s = fs::read_to_string(self.members_path()).context("read members.json")?;
|
||||||
|
Ok(serde_json::from_str(&s).context("parse members.json")?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_members(&self, members: &OrgMembers) -> Result<()> {
|
||||||
|
let json = serde_json::to_string_pretty(members)?;
|
||||||
|
atomic_write(&self.members_path(), json.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_collections(&self) -> Result<OrgCollections> {
|
||||||
|
let s = fs::read_to_string(self.collections_path()).context("read collections.json")?;
|
||||||
|
Ok(serde_json::from_str(&s).context("parse collections.json")?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_collections(&self, collections: &OrgCollections) -> Result<()> {
|
||||||
|
let json = serde_json::to_string_pretty(collections)?;
|
||||||
|
atomic_write(&self.collections_path(), json.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_manifest(&self) -> Result<OrgManifest> {
|
||||||
|
let bytes = fs::read(self.manifest_path()).context("read manifest.enc")?;
|
||||||
|
Ok(decrypt_org_manifest(&bytes, &self.org_key)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_manifest(&self, manifest: &OrgManifest) -> Result<()> {
|
||||||
|
let bytes = encrypt_org_manifest(manifest, &self.org_key)?;
|
||||||
|
atomic_write(&self.manifest_path(), &bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt + write an item under its collection directory, creating the
|
||||||
|
/// directory if needed. Returns the repo-relative path for git staging.
|
||||||
|
pub fn save_item(&self, collection_slug: &str, item: &Item) -> Result<String> {
|
||||||
|
let path = self.item_path(collection_slug, &item.id);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("create {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let bytes = encrypt_item(item, &self.org_key)?;
|
||||||
|
atomic_write(&path, &bytes)?;
|
||||||
|
Ok(format!("items/{}/{}.enc", collection_slug, item.id.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read + decrypt an item from its collection directory.
|
||||||
|
pub fn load_item(&self, collection_slug: &str, id: &ItemId) -> Result<Item> {
|
||||||
|
let path = self.item_path(collection_slug, id);
|
||||||
|
let bytes = fs::read(&path)
|
||||||
|
.with_context(|| format!("read item {}", path.display()))?;
|
||||||
|
Ok(decrypt_item(&bytes, &self.org_key)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an item blob. Missing file is not an error (partial-write
|
||||||
|
/// recovery, same as the personal-vault purge path).
|
||||||
|
pub fn remove_item(&self, collection_slug: &str, id: &ItemId) -> Result<()> {
|
||||||
|
let path = self.item_path(collection_slug, id);
|
||||||
|
match fs::remove_file(&path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(anyhow::Error::from(e)
|
||||||
|
.context(format!("delete {}", path.display()))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bail unless `member` has `slug` in their collection grants. The slug
|
||||||
|
/// existence check is done separately by the caller against collections.json.
|
||||||
|
pub fn ensure_grant(member: &OrgMember, slug: &str) -> Result<()> {
|
||||||
|
if member.collections.iter().any(|c| c == slug) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
bail!(
|
||||||
|
"access denied: you do not have a grant for collection `{slug}` — ask an admin to run `relicario org grant`"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load members.json and find the caller's member entry by matching the
|
||||||
|
/// current device's ed25519 fingerprint against each member's pubkey
|
||||||
|
/// fingerprint. Fingerprint comparison (not raw OpenSSH-string equality)
|
||||||
|
/// tolerates comment/whitespace differences in the serialized key.
|
||||||
|
pub fn current_member(&self) -> Result<relicario_core::OrgMember> {
|
||||||
|
let device_fp = current_device_fingerprint()?;
|
||||||
|
let members = self.load_members()?;
|
||||||
|
members
|
||||||
|
.members
|
||||||
|
.into_iter()
|
||||||
|
.find(|m| {
|
||||||
|
relicario_core::fingerprint(&m.ed25519_pubkey)
|
||||||
|
.ok()
|
||||||
|
.as_deref()
|
||||||
|
== Some(device_fp.as_str())
|
||||||
|
})
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"your device key is not registered in this org — ask an admin to run `org add-member`"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locate the org vault root from RELICARIO_ORG_DIR env var or --dir flag value.
|
||||||
|
pub fn org_dir(dir_flag: Option<&std::path::Path>) -> Result<PathBuf> {
|
||||||
|
if let Some(d) = dir_flag {
|
||||||
|
return Ok(d.to_path_buf());
|
||||||
|
}
|
||||||
|
if let Ok(v) = std::env::var("RELICARIO_ORG_DIR") {
|
||||||
|
return Ok(PathBuf::from(v));
|
||||||
|
}
|
||||||
|
bail!("org vault location required: set RELICARIO_ORG_DIR or pass --dir <path>")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open an org vault: locate the root, read members.json to find the caller's
|
||||||
|
/// member entry (by ed25519 fingerprint), then unwrap their keys/<id>.enc to
|
||||||
|
/// recover the org master key.
|
||||||
|
pub fn open_org_vault(dir_flag: Option<&std::path::Path>) -> Result<UnlockedOrgVault> {
|
||||||
|
let root = org_dir(dir_flag)?;
|
||||||
|
|
||||||
|
let device_fp = current_device_fingerprint()?;
|
||||||
|
let members_json = fs::read_to_string(root.join("members.json"))
|
||||||
|
.context("read members.json — is this an org vault?")?;
|
||||||
|
let members: OrgMembers = serde_json::from_str(&members_json).context("parse members.json")?;
|
||||||
|
let member = members
|
||||||
|
.members
|
||||||
|
.iter()
|
||||||
|
.find(|m| {
|
||||||
|
relicario_core::fingerprint(&m.ed25519_pubkey)
|
||||||
|
.ok()
|
||||||
|
.as_deref()
|
||||||
|
== Some(device_fp.as_str())
|
||||||
|
})
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("your device key is not in this org"))?;
|
||||||
|
|
||||||
|
// Load this member's wrapped key blob.
|
||||||
|
let key_path = root
|
||||||
|
.join("keys")
|
||||||
|
.join(format!("{}.enc", member.member_id.as_str()));
|
||||||
|
let wrapped =
|
||||||
|
fs::read(&key_path).with_context(|| format!("read {}", key_path.display()))?;
|
||||||
|
|
||||||
|
// Recover the device ed25519 seed and unwrap.
|
||||||
|
let seed = current_device_seed()?;
|
||||||
|
let org_key = relicario_core::unwrap_org_key(&wrapped, &seed)?;
|
||||||
|
|
||||||
|
Ok(UnlockedOrgVault { root, org_key })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenSSH SHA-256 fingerprint of the active device's signing key.
|
||||||
|
fn current_device_fingerprint() -> Result<String> {
|
||||||
|
let name = crate::device::current_device()?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
|
||||||
|
let pub_path = crate::device::device_dir(&name)?.join("signing.pub");
|
||||||
|
let pubkey = fs::read_to_string(&pub_path)
|
||||||
|
.with_context(|| format!("read {}", pub_path.display()))?;
|
||||||
|
Ok(relicario_core::fingerprint(pubkey.trim())?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recover the active device's ed25519 seed (the 32-byte private scalar source)
|
||||||
|
/// from its OpenSSH `signing.key`, for ECIES unwrap.
|
||||||
|
fn current_device_seed() -> Result<Zeroizing<[u8; 32]>> {
|
||||||
|
let name = crate::device::current_device()?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
|
||||||
|
let key_pem = crate::device::load_signing_key(&name)?;
|
||||||
|
let private = ssh_key::PrivateKey::from_openssh(key_pem.as_str())
|
||||||
|
.map_err(|e| anyhow::anyhow!("parse device signing key: {e}"))?;
|
||||||
|
let ed = private
|
||||||
|
.key_data()
|
||||||
|
.ed25519()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("device signing key is not ed25519"))?;
|
||||||
|
// Ed25519PrivateKey derefs to its 32-byte seed.
|
||||||
|
let seed_bytes: &[u8] = ed.private.as_ref();
|
||||||
|
if seed_bytes.len() != 32 {
|
||||||
|
anyhow::bail!("ed25519 seed has wrong length: {}", seed_bytes.len());
|
||||||
|
}
|
||||||
|
let mut seed = Zeroizing::new([0u8; 32]);
|
||||||
|
seed.copy_from_slice(seed_bytes);
|
||||||
|
Ok(seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
|
||||||
|
let mut tmp = path.as_os_str().to_owned();
|
||||||
|
tmp.push(".tmp");
|
||||||
|
let tmp = PathBuf::from(tmp);
|
||||||
|
fs::write(&tmp, data).with_context(|| format!("write {}", tmp.display()))?;
|
||||||
|
fs::rename(&tmp, path).with_context(|| format!("rename {}", tmp.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `git <args>` in the org repo, capturing output and replaying it on
|
||||||
|
/// failure. Unlike `crate::helpers::git_run`, this does NOT inject
|
||||||
|
/// `commit.gpgsign=false` / `core.hooksPath=/dev/null`: org commits MUST be
|
||||||
|
/// signed (the pre-receive hook verifies every commit's signature), and the
|
||||||
|
/// repo's signing config is established by `configure_git_signing` during
|
||||||
|
/// `org init`.
|
||||||
|
pub(crate) fn org_git_run(root: &Path, args: &[&str], context: &str) -> Result<()> {
|
||||||
|
let output = std::process::Command::new("git")
|
||||||
|
.current_dir(root)
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.with_context(|| format!("{context}: failed to spawn git"))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
if !output.stdout.is_empty() {
|
||||||
|
eprint!("{}", String::from_utf8_lossy(&output.stdout));
|
||||||
|
}
|
||||||
|
if !output.stderr.is_empty() {
|
||||||
|
eprint!("{}", String::from_utf8_lossy(&output.stderr));
|
||||||
|
}
|
||||||
|
anyhow::bail!("{context}: git failed ({})", output.status);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
fn make_vault(key: Zeroizing<[u8; 32]>) -> (TempDir, UnlockedOrgVault) {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let root = dir.path().to_path_buf();
|
||||||
|
fs::create_dir_all(root.join("items")).unwrap();
|
||||||
|
fs::create_dir_all(root.join("keys")).unwrap();
|
||||||
|
let vault = UnlockedOrgVault { root, org_key: key };
|
||||||
|
(dir, vault)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unlocked_org_vault_paths() {
|
||||||
|
let key = Zeroizing::new([0u8; 32]);
|
||||||
|
let (dir, vault) = make_vault(key);
|
||||||
|
let root = dir.path().to_path_buf();
|
||||||
|
assert_eq!(vault.manifest_path(), root.join("manifest.enc"));
|
||||||
|
assert_eq!(
|
||||||
|
vault.member_key_path(&MemberId("abc0def1abc0def1".into())),
|
||||||
|
root.join("keys/abc0def1abc0def1.enc")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
vault.item_path("prod", &relicario_core::ItemId("0123456789abcdef".into())),
|
||||||
|
root.join("items/prod/0123456789abcdef.enc")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_and_load_manifest() {
|
||||||
|
let key = Zeroizing::new([0xAAu8; 32]);
|
||||||
|
let (dir, vault) = make_vault(key);
|
||||||
|
let _ = dir; // keep alive
|
||||||
|
let mut m = OrgManifest::new();
|
||||||
|
m.entries.push(relicario_core::OrgManifestEntry {
|
||||||
|
id: relicario_core::ItemId::new(),
|
||||||
|
r#type: relicario_core::ItemType::SecureNote,
|
||||||
|
title: "test".into(),
|
||||||
|
tags: vec![],
|
||||||
|
modified: 0,
|
||||||
|
trashed_at: None,
|
||||||
|
collection: "prod".into(),
|
||||||
|
});
|
||||||
|
vault.save_manifest(&m).unwrap();
|
||||||
|
let loaded = vault.load_manifest().unwrap();
|
||||||
|
assert_eq!(loaded.entries.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_and_load_members() {
|
||||||
|
let key = Zeroizing::new([0u8; 32]);
|
||||||
|
let (dir, vault) = make_vault(key);
|
||||||
|
let _ = dir;
|
||||||
|
let members = OrgMembers::new();
|
||||||
|
vault.save_members(&members).unwrap();
|
||||||
|
let loaded = vault.load_members().unwrap();
|
||||||
|
assert_eq!(loaded.schema_version, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user