Compare commits

..

1 Commits

Author SHA1 Message Date
adlee-was-taken
d32af594e4 feat(server): grant-scope org attachment write paths in pre-receive hook 2026-06-20 17:30:49 -04:00
13 changed files with 536 additions and 476 deletions

2
Cargo.lock generated
View File

@@ -2220,7 +2220,7 @@ dependencies = [
[[package]] [[package]]
name = "relicario-server" name = "relicario-server"
version = "0.1.0" version = "0.1.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"assert_cmd", "assert_cmd",

View File

@@ -37,28 +37,15 @@ under `src/commands/`. Each source file has one job.
`cmd_history`), `edit`, `trash` (rm / restore / purge / trash empty), `cmd_history`), `edit`, `trash` (rm / restore / purge / trash empty),
`backup` (export / restore), `import` (lastpass), `attach` (attach / `backup` (export / restore), `import` (lastpass), `attach` (attach /
attachments / extract / detach), `generate`, `settings`, `sync`, `status`, attachments / extract / detach), `generate`, `settings`, `sync`, `status`,
`rate`, `device`, `recovery_qr`. `add` and `edit` resolve their non-secret `rate`, `device`, `recovery_qr`. `add` and `edit` each fan out internally to
fields then delegate to the shared `item_build` module's per-`ItemCore` per-`ItemCore` helpers (`build_<type>_item`, `edit_<type>`) so each
`build_*` / `edit_*` helpers (see the next bullet), so each builder/editor builder/editor reads top-to-bottom and can be tested through the same
reads top-to-bottom and can be tested through the same integration paths. integration paths.
- **`src/commands/item_build.rs`** — shared per-type item construction and
interactive editing used by BOTH personal (`add.rs`, `edit.rs`) and org
(`org.rs`) handlers, so the two surfaces cannot drift. Contains: secret
resolution (`resolve_secret_line` — reads one line from stdin or falls back
to an interactive masked prompt; `resolve_secret_multiline` — reads stdin to
EOF, printing an optional hint in the interactive case); type parsers
(`parse_card_kind`, `parse_totp_algorithm`); the seven `build_*` builders
(`build_login`, `build_secure_note`, `build_identity`, `build_card`,
`build_key`, `build_document`, `build_totp`); per-type `edit_*` helpers
(`edit_login`, `edit_secure_note`, `edit_card`, `edit_key`, `edit_totp`,
`edit_identity`, `edit_document_message`); and `push_history`.
- **`src/prompt.rs`** — interactive prompt primitives shared across commands: - **`src/prompt.rs`** — interactive prompt primitives shared across commands:
`prompt_keep`, `prompt_keep_opt`, `prompt_yesno`, `prompt_secret`, and the `prompt`, `prompt_optional`, `prompt_keep`, `prompt_keep_opt`,
flag-or-prompt pair `prompt_or_flag` / `prompt_or_flag_optional`. `prompt_yesno`, `prompt_secret`. `prompt_secret` honours
`prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET` before falling back to `RELICARIO_TEST_ITEM_SECRET` before falling back to `rpassword`.
`rpassword`.
- **`src/parse.rs`** — pure parsers for CLI-typed inputs (e.g. MonthYear - **`src/parse.rs`** — pure parsers for CLI-typed inputs (e.g. MonthYear
expiries, TOTP `otpauth://` URIs, comma-separated tag lists). No I/O. expiries, TOTP `otpauth://` URIs, comma-separated tag lists). No I/O.
@@ -180,7 +167,7 @@ in code; cite the line if you change it.
works without any setup. works without any setup.
- **Item IDs are minted by core.** The CLI never constructs an `ItemId` - **Item IDs are minted by core.** The CLI never constructs an `ItemId`
directly; `Item::new` (called inside every `item_build::build_*`) does it via directly; `Item::new` (called inside every `build_*_item`) does it via
`relicario-core::ids::new_item_id`. `ItemId`s are 8-char hex. `relicario-core::ids::new_item_id`. `ItemId`s are 8-char hex.
- **Manifest is always saved last.** Within a single command, the order is: - **Manifest is always saved last.** Within a single command, the order is:
@@ -250,23 +237,15 @@ in code; cite the line if you change it.
### Item add (`cmd_add`, `main.rs:419-456`) ### Item add (`cmd_add`, `main.rs:419-456`)
1. Unlock the vault and load the manifest. 1. Unlock the vault and load the manifest.
2. Match on the `AddKind` variant: resolve `title` and non-secret fields 2. Match on the `AddKind` variant and dispatch to the matching
(username, URL, holder, expiry, etc.) via `prompt_or_flag` / `build_<type>_item` helper (`main.rs:423-438`). Seven variants → seven
`prompt_or_flag_optional`, then delegate to the matching `build_*` builder builders; only `build_document_item` takes `&UnlockedVault` because it
in `commands/item_build.rs`. Seven variants → seven builders; only needs `attachment_caps` and writes the encrypted blob alongside the item.
`build_document` takes `&UnlockedVault` because it needs `attachment_caps` 3. The builder returns a fully-populated `Item` (with title, group, tags,
and writes the encrypted blob alongside the item.
3. Single-line secrets (Login password, Card number/CVV/PIN, TOTP secret)
accept a `--*-stdin` flag that reads one line from stdin instead of
prompting; multiline secrets (SecureNote body, Key material) always read
stdin to EOF — `--body-stdin` / `--material-stdin` suppress the interactive
Ctrl-D hint. Secret-resolution rule: `commands/item_build.rs`
`resolve_secret_line` / `resolve_secret_multiline`.
4. The builder returns a fully-populated `Item` (with title, group, tags,
favorite-flag, primary attachment if any). favorite-flag, primary attachment if any).
5. Common wrap-up: `vault.save_item(&item)`, `manifest.upsert(&item)`, 4. Common wrap-up: `vault.save_item(&item)`, `manifest.upsert(&item)`,
`vault.save_manifest(&manifest)`. `vault.save_manifest(&manifest)`.
6. Build the path list — `items/<id>.enc`, `manifest.enc`, plus one 5. Build the path list — `items/<id>.enc`, `manifest.enc`, plus one
`attachments/<id>/<aid>.enc` per attachment — and call `commit_paths` `attachments/<id>/<aid>.enc` per attachment — and call `commit_paths`
with message `add: <title> (<id>)` (`main.rs:444-452`). with message `add: <title> (<id>)` (`main.rs:444-452`).
@@ -599,12 +578,11 @@ applies to `relicario-core` unit tests, not these CLI integration tests.
instead. Non-primary attachments on a Document (e.g., a scanned instead. Non-primary attachments on a Document (e.g., a scanned
contract with an addendum) detach normally. contract with an addendum) detach normally.
- **Per-type `build_*` / `edit_*` helpers exist by design** (extracted in the - **Per-type `build_*_item` / `edit_*` helpers exist by design after the
`3f0f5b1` refactor, then centralized in `item_build.rs` for v0.8.1 so the `3f0f5b1` refactor.** Before the refactor, `cmd_add` and `cmd_edit`
personal and org surfaces share one set). Before the extraction, `cmd_add` carried 217-line `match` arms. The split-out functions are easier to
and `cmd_edit` carried 217-line `match` arms. The split-out functions are read, easier to test individually (the existing integration tests still
easier to read, easier to test individually (the existing integration tests drive them through the same paths), and easier to grow when a new
still drive them through the same paths), and easier to grow when a new
`ItemCore` variant lands. Keep this shape — don't fold them back. `ItemCore` variant lands. Keep this shape — don't fold them back.
- **Why the CLI shells out to `git`, not libgit2 / gitoxide.** Three - **Why the CLI shells out to `git`, not libgit2 / gitoxide.** Three

View File

@@ -1,76 +1,37 @@
//! `relicario add <kind>` — create a new item of the given type. //! `relicario add <kind>` — create a new item of the given type.
//! //!
//! `cmd_add` resolves `title` / non-secret prompts, then delegates to the //! `cmd_add` does the common save / manifest upsert / commit dance. The seven
//! shared builders in `commands/item_build.rs`. Group / tags / favorite are //! per-type `build_*_item` helpers each return a fully-populated `Item`. The
//! set AFTER the build so the builders stay portable to the org vault. //! `Document` builder is the only one that needs the unlocked vault (for the
//! attachment-cap settings + writing the encrypted blob alongside the item).
use anyhow::Result; use std::path::PathBuf;
use anyhow::{Context, Result};
use crate::AddKind; use crate::AddKind;
use crate::commands::item_build as ib; use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year};
use crate::prompt::{prompt_or_flag, prompt_or_flag_optional}; use crate::prompt::{prompt, prompt_optional, prompt_or_flag, prompt_or_flag_optional, prompt_secret};
pub fn cmd_add(kind: AddKind) -> Result<()> { pub fn cmd_add(kind: AddKind) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?; let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?; let mut manifest = vault.load_manifest()?;
let item = match kind { let item = match kind {
AddKind::Login { title, username, url, password_prompt, password, password_stdin, group, tags, favorite, totp_qr } => { AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite, totp_qr } =>
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?; build_login_item(title, username, url, password_prompt, password, group, tags, favorite, totp_qr)?,
let username = prompt_or_flag_optional(username, "Username", |s| Ok(s.to_string()))?; AddKind::SecureNote { title, body_prompt, group, tags } =>
let url = prompt_or_flag_optional(url, "URL", |s| Ok(s.to_string()))?; build_secure_note_item(title, body_prompt, group, tags)?,
let mut item = ib::build_login(title, username, url, password, password_stdin, password_prompt, totp_qr)?; AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } =>
item.group = group; item.tags = tags; item.favorite = favorite; build_identity_item(title, full_name, email, phone, date_of_birth, group, tags)?,
item AddKind::Card { title, holder, expiry, kind, group, tags } =>
} build_card_item(title, holder, expiry, kind, group, tags)?,
AddKind::SecureNote { title, body_stdin, group, tags } => { AddKind::Key { title, label, algorithm, group, tags } =>
// Per the v0.8.1 spec's unified secret model, a note body is a build_key_item(title, label, algorithm, group, tags)?,
// multiline secret that always reads stdin to EOF. `body_stdin=false` AddKind::Document { title, file, group, tags } =>
// means "print the Ctrl-D hint" (interactive default); `true` suppresses build_document_item(&vault, title, file, group, tags)?,
// the hint for non-interactive use. AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } =>
// Secret-resolution rule: `commands/item_build.rs` `resolve_secret_multiline`. build_totp_item(title, issuer, label, secret, period, digits, algorithm, group, tags)?,
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let mut item = ib::build_secure_note(title, None, body_stdin)?;
item.group = group; item.tags = tags;
item
}
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } => {
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let mut item = ib::build_identity(title, full_name, email, phone, date_of_birth)?;
item.group = group; item.tags = tags;
item
}
AddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin, group, tags } => {
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let mut item = ib::build_card(title, holder, expiry, &kind, number_stdin, cvv_stdin, pin_stdin)?;
item.group = group; item.tags = tags;
item
}
AddKind::Key { title, label, algorithm, material_stdin, group, tags } => {
// public_key is None for the personal vault: the legacy `prompt_optional`
// for it was unreachable (stdin already at EOF after the key-material read).
// Org `add key` (Dev-B) supplies it via --public-key.
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let mut item = ib::build_key(title, label, algorithm, None, material_stdin)?;
item.group = group; item.tags = tags;
item
}
AddKind::Document { title, file, group, tags } => {
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let caps = vault.load_settings()?.attachment_caps;
let (mut item, enc) = ib::build_document(title, file, vault.key(), caps.per_attachment_max_bytes)?;
item.group = group; item.tags = tags;
let att_dir = vault.root().join("attachments").join(item.id.as_str());
std::fs::create_dir_all(&att_dir)?;
std::fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?;
item
}
AddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm, group, tags } => {
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let mut item = ib::build_totp(title, issuer, label, secret, secret_stdin, period, digits, &algorithm)?;
item.group = group; item.tags = tags;
item
}
}; };
vault.save_item(&item)?; vault.save_item(&item)?;
@@ -90,3 +51,263 @@ pub fn cmd_add(kind: AddKind) -> Result<()> {
eprintln!("Added: {} (id={})", item.title, item.id.as_str()); eprintln!("Added: {} (id={})", item.title, item.id.as_str());
Ok(()) Ok(())
} }
#[allow(clippy::too_many_arguments)]
fn build_login_item(
title: Option<String>,
username: Option<String>,
url: Option<String>,
password_prompt: bool,
password: Option<String>,
group: Option<String>,
tags: Vec<String>,
favorite: bool,
totp_qr: Option<PathBuf>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::{LoginCore, TotpAlgorithm, TotpConfig, TotpKind};
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let username = prompt_or_flag_optional(username, "Username", |s| Ok(s.to_string()))?;
let url = prompt_or_flag_optional(url, "URL", |s| Ok(s.to_string()))?;
let parsed_url = match url {
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
None => None,
};
let password = if let Some(p) = password {
Some(Zeroizing::new(p))
} else if password_prompt {
Some(Zeroizing::new(prompt_secret("Password: ")?))
} else {
None
};
let totp = if let Some(path) = totp_qr {
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
let secret_bytes = base32_decode_lenient(&secret_b32)?;
Some(TotpConfig {
secret: Zeroizing::new(secret_bytes),
algorithm: TotpAlgorithm::Sha1,
digits: 6,
period_seconds: 30,
kind: TotpKind::Totp,
})
} else {
None
};
let mut item = Item::new(title, ItemCore::Login(LoginCore {
username, password, url: parsed_url, totp,
}));
item.group = group;
item.tags = tags;
item.favorite = favorite;
Ok(item)
}
fn build_secure_note_item(
title: Option<String>,
body_prompt: bool,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::SecureNoteCore;
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let body = if body_prompt {
eprintln!("Enter note body; end with Ctrl-D on a blank line:");
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
s
} else {
prompt("Body")?
};
let mut item = Item::new(title, ItemCore::SecureNote(SecureNoteCore {
body: Zeroizing::new(body),
}));
item.group = group;
item.tags = tags;
Ok(item)
}
fn build_identity_item(
title: Option<String>,
full_name: Option<String>,
email: Option<String>,
phone: Option<String>,
date_of_birth: Option<String>,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::IdentityCore;
use relicario_core::{Item, ItemCore};
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let dob = match date_of_birth {
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
None => None,
};
let mut item = Item::new(title, ItemCore::Identity(IdentityCore {
full_name, address: None, phone, email, date_of_birth: dob,
}));
item.group = group;
item.tags = tags;
Ok(item)
}
fn build_card_item(
title: Option<String>,
holder: Option<String>,
expiry: Option<String>,
kind: String,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::{CardCore, CardKind};
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let number = Zeroizing::new(prompt_secret("Card number: ")?);
let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?);
let cvv = if cvv.is_empty() { None } else { Some(cvv) };
let pin = Zeroizing::new(prompt_secret("PIN (blank to skip): ")?);
let pin = if pin.is_empty() { None } else { Some(pin) };
let parsed_expiry = match expiry {
Some(s) => Some(parse_month_year(&s)?),
None => None,
};
let parsed_kind = match kind.as_str() {
"credit" => CardKind::Credit,
"debit" => CardKind::Debit,
"gift" => CardKind::Gift,
"loyalty" => CardKind::Loyalty,
"other" => CardKind::Other,
other => anyhow::bail!("unknown card kind: {other}"),
};
let mut item = Item::new(title, ItemCore::Card(CardCore {
number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parsed_kind,
}));
item.group = group;
item.tags = tags;
Ok(item)
}
fn build_key_item(
title: Option<String>,
label: Option<String>,
algorithm: Option<String>,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::KeyCore;
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
eprintln!("Paste key material; end with Ctrl-D on a blank line:");
let mut key_material = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?;
if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
let public_key = prompt_optional("Public key (blank to skip)")?;
let mut item = Item::new(title, ItemCore::Key(KeyCore {
key_material: Zeroizing::new(key_material),
label, public_key, algorithm,
}));
item.group = group;
item.tags = tags;
Ok(item)
}
fn build_document_item(
vault: &crate::session::UnlockedVault,
title: Option<String>,
file: PathBuf,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::DocumentCore;
use relicario_core::{encrypt_attachment, AttachmentRef, Item, ItemCore};
use std::fs;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let bytes = fs::read(&file)
.with_context(|| format!("failed to read {}", file.display()))?;
let caps = vault.load_settings()?.attachment_caps;
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
let filename = file.file_name()
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
.to_string_lossy()
.into_owned();
let mime_type = guess_mime(&filename);
let primary_attachment = enc.id.clone();
let mut item = Item::new(title, ItemCore::Document(DocumentCore {
filename: filename.clone(),
mime_type: mime_type.clone(),
primary_attachment: primary_attachment.clone(),
}));
item.group = group;
item.tags = tags;
item.attachments.push(AttachmentRef {
id: primary_attachment.clone(),
filename, mime_type,
size: bytes.len() as u64,
created: item.created,
});
let att_dir = vault.root().join("attachments").join(item.id.as_str());
fs::create_dir_all(&att_dir)?;
fs::write(att_dir.join(format!("{}.enc", primary_attachment.as_str())), &enc.bytes)?;
Ok(item)
}
#[allow(clippy::too_many_arguments)]
fn build_totp_item(
title: Option<String>,
issuer: Option<String>,
label: Option<String>,
secret: Option<String>,
period: u32,
digits: u8,
algorithm: String,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpCore, TotpKind};
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let secret_b32 = match secret {
Some(s) => s,
None => prompt_secret("TOTP secret (base32): ")?,
};
let secret_bytes = base32_decode_lenient(&secret_b32)?;
let algo = match algorithm.to_ascii_lowercase().as_str() {
"sha1" => TotpAlgorithm::Sha1,
"sha256" => TotpAlgorithm::Sha256,
"sha512" => TotpAlgorithm::Sha512,
other => anyhow::bail!("unknown algorithm: {other}"),
};
let mut item = Item::new(title, ItemCore::Totp(TotpCore {
config: TotpConfig {
secret: Zeroizing::new(secret_bytes),
algorithm: algo,
digits,
period_seconds: period,
kind: TotpKind::Totp,
},
issuer, label,
}));
item.group = group;
item.tags = tags;
Ok(item)
}

View File

@@ -2,9 +2,10 @@
use std::path::PathBuf; use std::path::PathBuf;
use anyhow::Result; use anyhow::{Context, Result};
use crate::prompt::{prompt_keep, prompt_keep_opt}; use crate::parse::base32_decode_lenient;
use crate::prompt::{prompt_keep, prompt_keep_opt, prompt_secret, prompt_yesno};
pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> { pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
use relicario_core::time::now_unix; use relicario_core::time::now_unix;
@@ -28,13 +29,13 @@ pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
let history = &mut item.field_history; let history = &mut item.field_history;
match &mut item.core { match &mut item.core {
ItemCore::Login(l) => crate::commands::item_build::edit_login(l, history, totp_qr)?, ItemCore::Login(l) => edit_login(l, history, totp_qr)?,
ItemCore::SecureNote(n) => crate::commands::item_build::edit_secure_note(n, history)?, ItemCore::SecureNote(n) => edit_secure_note(n, history)?,
ItemCore::Identity(i) => crate::commands::item_build::edit_identity(i)?, ItemCore::Identity(i) => edit_identity(i)?,
ItemCore::Card(c) => crate::commands::item_build::edit_card(c, history)?, ItemCore::Card(c) => edit_card(c, history)?,
ItemCore::Key(k) => crate::commands::item_build::edit_key(k, history)?, ItemCore::Key(k) => edit_key(k, history)?,
ItemCore::Document(_) => crate::commands::item_build::edit_document_message(), ItemCore::Document(_) => edit_document_message(),
ItemCore::Totp(t) => crate::commands::item_build::edit_totp(t, history)?, ItemCore::Totp(t) => edit_totp(t, history)?,
} }
item.modified = now_unix(); item.modified = now_unix();
@@ -46,3 +47,125 @@ pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
eprintln!("Updated {}", item.id.as_str()); eprintln!("Updated {}", item.id.as_str());
Ok(()) Ok(())
} }
// --- Per-type edit handlers. Each mutates its core slice in place; the ones
// that touch history-tracked fields take the item's field_history map so
// they can record the prior value alongside the change.
type FieldHistory = std::collections::HashMap<
relicario_core::FieldId,
Vec<relicario_core::item::FieldHistoryEntry>,
>;
fn edit_login(
l: &mut relicario_core::item_types::LoginCore,
history: &mut FieldHistory,
totp_qr: Option<PathBuf>,
) -> Result<()> {
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpKind};
use zeroize::Zeroizing;
if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?);
}
if prompt_yesno("Change password?")? {
let old = l.password.clone();
l.password = Some(Zeroizing::new(prompt_secret("New password: ")?));
if let Some(old_pw) = old {
push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string()));
}
}
if let Some(path) = totp_qr {
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
let secret_bytes = base32_decode_lenient(&secret_b32)?;
l.totp = Some(TotpConfig {
secret: Zeroizing::new(secret_bytes),
algorithm: TotpAlgorithm::Sha1,
digits: 6,
period_seconds: 30,
kind: TotpKind::Totp,
});
eprintln!("TOTP secret set from QR image.");
}
Ok(())
}
fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> {
use zeroize::Zeroizing;
if prompt_yesno("Edit body?")? {
let old = n.body.clone();
eprintln!("Enter new body; end with Ctrl-D:");
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
n.body = Zeroizing::new(s);
push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string()));
}
Ok(())
}
fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()> {
if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); }
if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); }
if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); }
Ok(())
}
fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> {
use zeroize::Zeroizing;
if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
if prompt_yesno("Change card number?")? {
let old = c.number.clone();
c.number = Some(Zeroizing::new(prompt_secret("New number: ")?));
if let Some(o) = old {
push_history(history, "card_number", Zeroizing::new(o.as_str().to_string()));
}
}
Ok(())
}
fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> {
use zeroize::Zeroizing;
if prompt_yesno("Replace key material?")? {
eprintln!("Paste new key material; end with Ctrl-D:");
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
let old = k.key_material.clone();
k.key_material = Zeroizing::new(s);
push_history(history, "key_material", Zeroizing::new(old.as_str().to_string()));
}
Ok(())
}
fn edit_document_message() {
eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
}
fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> {
use zeroize::Zeroizing;
if let Some(v) = prompt_keep_opt("Issuer", t.issuer.as_deref())? { t.issuer = Some(v); }
if let Some(v) = prompt_keep_opt("Label", t.label.as_deref())? { t.label = Some(v); }
if prompt_yesno("Change TOTP secret?")? {
let old_b32 = data_encoding::BASE32.encode(&t.config.secret);
let new_b32 = prompt_secret("New TOTP secret (base32): ")?;
let new_bytes = base32_decode_lenient(&new_b32)?;
t.config.secret = Zeroizing::new(new_bytes);
push_history(history, "totp_secret", Zeroizing::new(old_b32));
}
Ok(())
}
fn push_history(
history: &mut std::collections::HashMap<relicario_core::FieldId, Vec<relicario_core::item::FieldHistoryEntry>>,
synthetic_key: &str,
old_value: zeroize::Zeroizing<String>,
) {
use relicario_core::item::FieldHistoryEntry;
use relicario_core::time::now_unix;
// Synthetic FieldId for core-level fields — stable per-item (prefixed so
// custom-field UUIDs can't collide).
let fid = relicario_core::FieldId(format!("core:{synthetic_key}"));
history.entry(fid).or_default().push(FieldHistoryEntry {
value: old_value,
replaced_at: now_unix(),
});
}

View File

@@ -1,318 +0,0 @@
//! Shared per-type item construction + interactive editing for both the
//! personal vault (`commands/add.rs`, `commands/edit.rs`) and the org vault
//! (`commands/org.rs`). Centralizing it keeps the two surfaces from drifting.
use std::collections::HashMap;
use std::path::PathBuf;
use anyhow::{Context, Result};
use zeroize::Zeroizing;
use relicario_core::item::FieldHistoryEntry;
use relicario_core::item_types::{CardKind, TotpAlgorithm};
use relicario_core::time::now_unix;
use relicario_core::{EncryptedAttachment, FieldId, Item, ItemCore};
use crate::parse::base32_decode_lenient;
use crate::prompt::{prompt_keep_opt, prompt_secret, prompt_yesno};
pub(crate) type FieldHistory = HashMap<FieldId, Vec<FieldHistoryEntry>>;
/// Resolve a single-line secret: from stdin when `from_stdin`, else an
/// interactive masked prompt (which honours `RELICARIO_TEST_ITEM_SECRET`).
pub(crate) fn resolve_secret_line(from_stdin: bool, label: &str) -> Result<String> {
if from_stdin {
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
Ok(s.trim_end_matches(['\n', '\r']).to_string())
} else {
crate::prompt::prompt_secret(&format!("{label}: "))
}
}
/// Resolve a multiline secret (key material, note body). Both paths read stdin
/// to EOF; the interactive path first prints `hint` to stderr.
pub(crate) fn resolve_secret_multiline(from_stdin: bool, hint: &str) -> Result<String> {
if !from_stdin {
eprintln!("{hint}");
}
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
Ok(s)
}
pub(crate) fn parse_card_kind(s: &str) -> Result<CardKind> {
Ok(match s {
"credit" => CardKind::Credit,
"debit" => CardKind::Debit,
"gift" => CardKind::Gift,
"loyalty" => CardKind::Loyalty,
"other" => CardKind::Other,
other => anyhow::bail!("unknown card kind: {other}"),
})
}
pub(crate) fn parse_totp_algorithm(s: &str) -> Result<TotpAlgorithm> {
Ok(match s.to_ascii_lowercase().as_str() {
"sha1" => TotpAlgorithm::Sha1,
"sha256" => TotpAlgorithm::Sha256,
"sha512" => TotpAlgorithm::Sha512,
other => anyhow::bail!("unknown algorithm: {other}"),
})
}
// --- Per-type interactive edit helpers (moved from commands/edit.rs). Each
// mutates its core slice in place; history-tracked variants take the
// item's field_history map so they can record the prior value.
pub(crate) fn edit_login(
l: &mut relicario_core::item_types::LoginCore,
history: &mut FieldHistory,
totp_qr: Option<PathBuf>,
) -> Result<()> {
use relicario_core::item_types::{TotpConfig, TotpKind};
if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?);
}
if prompt_yesno("Change password?")? {
let old = l.password.clone();
l.password = Some(Zeroizing::new(prompt_secret("New password: ")?));
if let Some(old_pw) = old {
push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string()));
}
}
if let Some(path) = totp_qr {
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
let secret_bytes = base32_decode_lenient(&secret_b32)?;
l.totp = Some(TotpConfig {
secret: Zeroizing::new(secret_bytes),
algorithm: TotpAlgorithm::Sha1,
digits: 6,
period_seconds: 30,
kind: TotpKind::Totp,
});
eprintln!("TOTP secret set from QR image.");
}
Ok(())
}
pub(crate) fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> {
if prompt_yesno("Edit body?")? {
let old = n.body.clone();
let s = resolve_secret_multiline(false, "Enter new body; end with Ctrl-D:")?;
n.body = Zeroizing::new(s);
push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string()));
}
Ok(())
}
pub(crate) fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()> {
if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); }
if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); }
if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); }
Ok(())
}
pub(crate) fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> {
if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
if prompt_yesno("Change card number?")? {
let old = c.number.clone();
c.number = Some(Zeroizing::new(prompt_secret("New number: ")?));
if let Some(o) = old {
push_history(history, "card_number", Zeroizing::new(o.as_str().to_string()));
}
}
Ok(())
}
pub(crate) fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> {
if prompt_yesno("Replace key material?")? {
let s = resolve_secret_multiline(false, "Paste new key material; end with Ctrl-D:")?;
let old = k.key_material.clone();
k.key_material = Zeroizing::new(s);
push_history(history, "key_material", Zeroizing::new(old.as_str().to_string()));
}
Ok(())
}
pub(crate) fn edit_document_message() {
eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
}
pub(crate) fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> {
if let Some(v) = prompt_keep_opt("Issuer", t.issuer.as_deref())? { t.issuer = Some(v); }
if let Some(v) = prompt_keep_opt("Label", t.label.as_deref())? { t.label = Some(v); }
if prompt_yesno("Change TOTP secret?")? {
let old_b32 = data_encoding::BASE32.encode(&t.config.secret);
let new_b32 = prompt_secret("New TOTP secret (base32): ")?;
let new_bytes = base32_decode_lenient(&new_b32)?;
t.config.secret = Zeroizing::new(new_bytes);
push_history(history, "totp_secret", Zeroizing::new(old_b32));
}
Ok(())
}
pub(crate) fn build_login(
title: String, username: Option<String>, url: Option<String>,
password: Option<String>, password_stdin: bool, password_prompt: bool,
totp_qr: Option<PathBuf>,
) -> Result<Item> {
use relicario_core::item_types::{LoginCore, TotpConfig, TotpKind};
let parsed_url = match url {
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
None => None,
};
let password = if let Some(p) = password {
Some(Zeroizing::new(p))
} else if password_stdin {
Some(Zeroizing::new(resolve_secret_line(password_stdin, "Password")?))
} else if password_prompt {
Some(Zeroizing::new(crate::prompt::prompt_secret("Password: ")?))
} else {
None
};
let totp = if let Some(path) = totp_qr {
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
let secret_bytes = base32_decode_lenient(&secret_b32)?;
Some(TotpConfig {
secret: Zeroizing::new(secret_bytes), algorithm: TotpAlgorithm::Sha1,
digits: 6, period_seconds: 30, kind: TotpKind::Totp,
})
} else { None };
Ok(Item::new(title, ItemCore::Login(LoginCore { username, password, url: parsed_url, totp })))
}
pub(crate) fn build_secure_note(title: String, body: Option<String>, body_stdin: bool) -> Result<Item> {
use relicario_core::item_types::SecureNoteCore;
let body = match body {
Some(b) => b,
None => resolve_secret_multiline(body_stdin, "Enter note body; end with Ctrl-D on a blank line:")?,
};
Ok(Item::new(title, ItemCore::SecureNote(SecureNoteCore { body: Zeroizing::new(body) })))
}
pub(crate) fn build_identity(
title: String, full_name: Option<String>, email: Option<String>,
phone: Option<String>, date_of_birth: Option<String>,
) -> Result<Item> {
use relicario_core::item_types::IdentityCore;
let dob = match date_of_birth {
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
None => None,
};
Ok(Item::new(title, ItemCore::Identity(IdentityCore {
full_name, address: None, phone, email, date_of_birth: dob,
})))
}
pub(crate) fn build_card(
title: String, holder: Option<String>, expiry: Option<String>, kind: &str,
number_stdin: bool, cvv_stdin: bool, pin_stdin: bool,
) -> Result<Item> {
use relicario_core::item_types::CardCore;
let number = Zeroizing::new(resolve_secret_line(number_stdin, "Card number")?);
let cvv = resolve_secret_line(cvv_stdin, "CVV (blank to skip)")?;
let cvv = if cvv.is_empty() { None } else { Some(Zeroizing::new(cvv)) };
let pin = resolve_secret_line(pin_stdin, "PIN (blank to skip)")?;
let pin = if pin.is_empty() { None } else { Some(Zeroizing::new(pin)) };
let parsed_expiry = match expiry { Some(s) => Some(crate::parse::parse_month_year(&s)?), None => None };
Ok(Item::new(title, ItemCore::Card(CardCore {
number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parse_card_kind(kind)?,
})))
}
pub(crate) fn build_key(
title: String, label: Option<String>, algorithm: Option<String>,
public_key: Option<String>, material_stdin: bool,
) -> Result<Item> {
use relicario_core::item_types::KeyCore;
let key_material = resolve_secret_multiline(material_stdin, "Paste key material; end with Ctrl-D on a blank line:")?;
if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
Ok(Item::new(title, ItemCore::Key(KeyCore {
key_material: Zeroizing::new(key_material), label, public_key, algorithm,
})))
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_totp(
title: String, issuer: Option<String>, label: Option<String>,
secret: Option<String>, secret_stdin: bool, period: u32, digits: u8, algorithm: &str,
) -> Result<Item> {
use relicario_core::item_types::{TotpConfig, TotpCore, TotpKind};
let secret_b32 = match secret {
Some(s) => s,
None => resolve_secret_line(secret_stdin, "TOTP secret (base32)")?,
};
let secret_bytes = base32_decode_lenient(&secret_b32)?;
Ok(Item::new(title, ItemCore::Totp(TotpCore {
config: TotpConfig {
secret: Zeroizing::new(secret_bytes), algorithm: parse_totp_algorithm(algorithm)?,
digits, period_seconds: period, kind: TotpKind::Totp,
},
issuer, label,
})))
}
pub(crate) fn build_document(
title: String, file: PathBuf, key: &Zeroizing<[u8; 32]>, max_bytes: u64,
) -> Result<(Item, EncryptedAttachment)> {
use relicario_core::item_types::DocumentCore;
use relicario_core::{encrypt_attachment, AttachmentRef};
let bytes = std::fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?;
let enc = encrypt_attachment(&bytes, key, max_bytes)?;
let filename = file.file_name()
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
.to_string_lossy().into_owned();
let mime_type = crate::parse::guess_mime(&filename);
let primary_attachment = enc.id.clone();
let mut item = Item::new(title, ItemCore::Document(DocumentCore {
filename: filename.clone(), mime_type: mime_type.clone(), primary_attachment: primary_attachment.clone(),
}));
item.attachments.push(AttachmentRef {
id: primary_attachment, filename, mime_type, size: bytes.len() as u64, created: item.created,
});
Ok((item, enc))
}
pub(crate) fn push_history(
history: &mut FieldHistory,
synthetic_key: &str,
old_value: zeroize::Zeroizing<String>,
) {
// Synthetic FieldId for core-level fields — stable per-item (prefixed so
// custom-field UUIDs can't collide).
let fid = relicario_core::FieldId(format!("core:{synthetic_key}"));
history.entry(fid).or_default().push(FieldHistoryEntry {
value: old_value,
replaced_at: now_unix(),
});
}
#[cfg(test)]
mod tests {
use super::*;
use relicario_core::item_types::{CardKind, TotpAlgorithm};
#[test]
fn card_kind_parses_known_values() {
assert_eq!(parse_card_kind("credit").unwrap(), CardKind::Credit);
assert_eq!(parse_card_kind("loyalty").unwrap(), CardKind::Loyalty);
}
#[test]
fn card_kind_rejects_unknown() {
assert!(parse_card_kind("platinum").is_err());
}
#[test]
fn totp_algorithm_is_case_insensitive() {
assert_eq!(parse_totp_algorithm("SHA256").unwrap(), TotpAlgorithm::Sha256);
}
#[test]
fn totp_algorithm_rejects_unknown() {
assert!(parse_totp_algorithm("md5").is_err());
}
}

View File

@@ -14,7 +14,6 @@ pub mod edit;
pub mod generate; pub mod generate;
pub mod get; pub mod get;
pub mod import; pub mod import;
pub mod item_build;
pub mod org; pub mod org;
pub mod init; pub mod init;
pub mod list; pub mod list;

View File

@@ -227,8 +227,6 @@ pub(crate) enum AddKind {
/// Prompt for password (vs reading from stdin or --password). /// Prompt for password (vs reading from stdin or --password).
#[arg(long)] password_prompt: bool, #[arg(long)] password_prompt: bool,
#[arg(long)] password: Option<String>, #[arg(long)] password: Option<String>,
/// Read the password from stdin (one line) instead of prompting.
#[arg(long)] password_stdin: bool,
#[arg(long)] group: Option<String>, #[arg(long)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>, #[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] favorite: bool, #[arg(long)] favorite: bool,
@@ -237,8 +235,7 @@ pub(crate) enum AddKind {
}, },
SecureNote { SecureNote {
#[arg(long)] title: Option<String>, #[arg(long)] title: Option<String>,
/// Read the note body from stdin (to EOF) instead of printing the Ctrl-D hint. #[arg(long)] body_prompt: bool,
#[arg(long)] body_stdin: bool,
#[arg(long)] group: Option<String>, #[arg(long)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>, #[arg(long, value_delimiter = ',')] tags: Vec<String>,
}, },
@@ -256,12 +253,6 @@ pub(crate) enum AddKind {
#[arg(long)] holder: Option<String>, #[arg(long)] holder: Option<String>,
#[arg(long)] expiry: Option<String>, // MM/YYYY #[arg(long)] expiry: Option<String>, // MM/YYYY
#[arg(long, default_value = "credit")] kind: String, #[arg(long, default_value = "credit")] kind: String,
/// Read the card number from stdin (one line) instead of prompting.
#[arg(long)] number_stdin: bool,
/// Read the CVV from stdin (one line) instead of prompting.
#[arg(long)] cvv_stdin: bool,
/// Read the PIN from stdin (one line) instead of prompting.
#[arg(long)] pin_stdin: bool,
#[arg(long)] group: Option<String>, #[arg(long)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>, #[arg(long, value_delimiter = ',')] tags: Vec<String>,
}, },
@@ -269,8 +260,6 @@ pub(crate) enum AddKind {
#[arg(long)] title: Option<String>, #[arg(long)] title: Option<String>,
#[arg(long)] label: Option<String>, #[arg(long)] label: Option<String>,
#[arg(long)] algorithm: Option<String>, #[arg(long)] algorithm: Option<String>,
/// Read the key material from stdin (to EOF) instead of printing the Ctrl-D hint.
#[arg(long)] material_stdin: bool,
#[arg(long)] group: Option<String>, #[arg(long)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>, #[arg(long, value_delimiter = ',')] tags: Vec<String>,
}, },
@@ -285,8 +274,6 @@ pub(crate) enum AddKind {
#[arg(long)] issuer: Option<String>, #[arg(long)] issuer: Option<String>,
#[arg(long)] label: Option<String>, #[arg(long)] label: Option<String>,
#[arg(long)] secret: Option<String>, // base32 #[arg(long)] secret: Option<String>, // base32
/// Read the TOTP secret from stdin (one line) instead of prompting.
#[arg(long)] secret_stdin: bool,
#[arg(long, default_value = "30")] period: u32, #[arg(long, default_value = "30")] period: u32,
#[arg(long, default_value = "6")] digits: u8, #[arg(long, default_value = "6")] digits: u8,
#[arg(long, default_value = "sha1")] algorithm: String, #[arg(long, default_value = "sha1")] algorithm: String,

View File

@@ -1,12 +1,13 @@
//! Interactive prompt helpers for the CLI. //! Interactive prompt helpers for the CLI.
//! //!
//! `prompt_secret` reads a masked secret from the TTY (honouring //! The `prompt`/`prompt_optional`/`prompt_secret` family reads from stdin /
//! `RELICARIO_TEST_ITEM_SECRET` so integration tests without a TTY can inject //! the TTY; the `prompt_keep`/`prompt_keep_opt`/`prompt_yesno` variants are
//! secrets); the `prompt_keep`/`prompt_keep_opt`/`prompt_yesno` variants are
//! used by the edit handlers to keep current values when the user hits enter //! used by the edit handlers to keep current values when the user hits enter
//! at a blank prompt. `prompt_or_flag` and `prompt_or_flag_optional` thread a //! at a blank prompt. `prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET`
//! CLI-flag value through the same path so command handlers can use one call //! so integration tests (which don't have a TTY) can inject secrets.
//! site whether the value came from the command line or an interactive prompt. //! `prompt_or_flag` and `prompt_or_flag_optional` thread a CLI-flag value
//! through the same path so command handlers can use one call site whether
//! the value came from the command line or from an interactive prompt.
use anyhow::Result; use anyhow::Result;
use std::io::BufRead; use std::io::BufRead;
@@ -40,6 +41,18 @@ fn read_optional_line<R: BufRead>(reader: &mut R, label: &str) -> Result<Option<
Ok(if trimmed.is_empty() { None } else { Some(trimmed) }) Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
} }
pub(crate) fn prompt(label: &str) -> Result<String> {
let stdin = std::io::stdin();
let mut reader = std::io::BufReader::new(stdin.lock());
read_required_line(&mut reader, label)
}
pub(crate) fn prompt_optional(label: &str) -> Result<Option<String>> {
let stdin = std::io::stdin();
let mut reader = std::io::BufReader::new(stdin.lock());
read_optional_line(&mut reader, label)
}
pub(crate) fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> { pub(crate) fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
eprint!("{label} [{current}]: "); eprint!("{label} [{current}]: ");
std::io::Write::flush(&mut std::io::stderr())?; std::io::Write::flush(&mut std::io::stderr())?;

View File

@@ -201,20 +201,3 @@ fn generate_random_and_bip39() {
let phrase = String::from_utf8(out.stdout).unwrap(); let phrase = String::from_utf8(out.stdout).unwrap();
assert_eq!(phrase.trim().split(' ').count(), 5); assert_eq!(phrase.trim().split(' ').count(), 5);
} }
#[test]
fn add_card_via_stdin_flags_is_non_interactive() {
let v = TestVault::init();
let out = v.run_with_input(
&["add", "card", "--title", "Visa", "--kind", "credit",
"--number-stdin", "--cvv-stdin", "--pin-stdin"],
&["4111111111111111", "123", "4321"],
);
assert!(out.status.success(), "add card via stdin failed: {}", String::from_utf8_lossy(&out.stderr));
let got = v.run(&["get", "Visa"]);
assert!(got.status.success(), "get Visa failed: {}", String::from_utf8_lossy(&got.stderr));
let stdout = String::from_utf8_lossy(&got.stdout);
assert!(stdout.contains("********"), "card number should be masked without --show: {stdout}");
assert!(!stdout.contains("4111111111111111"), "card number leaked without --show: {stdout}");
}

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "relicario-server" name = "relicario-server"
version = "0.1.0" version = "0.1.1"
edition = "2021" edition = "2021"
description = "Pre-receive Git hook for relicario password manager" description = "Pre-receive Git hook for relicario password manager"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"

View File

@@ -6,7 +6,8 @@
pub enum PathClass { pub enum PathClass {
/// `members.json`, `collections.json`, `org.json` — only Owner/Admin may write. /// `members.json`, `collections.json`, `org.json` — only Owner/Admin may write.
Protected, Protected,
/// `items/<slug>/<id>.enc` — writer must hold a grant for `<slug>`. /// `items/<slug>/<id>.enc` and `attachments/<slug>/<item-id>/<att-id>.enc` —
/// writer must hold a grant for `<slug>`.
Item { collection: String }, Item { collection: String },
/// `keys/<id>.enc`, `manifest.enc`, `.gitignore`, etc. — gated only by the /// `keys/<id>.enc`, `manifest.enc`, `.gitignore`, etc. — gated only by the
/// per-commit signature check (signer must be a current member). /// per-commit signature check (signer must be a current member).
@@ -42,6 +43,23 @@ pub fn classify_path(path: &str) -> PathClass {
return PathClass::Item { collection: slug.to_string() }; return PathClass::Item { collection: slug.to_string() };
} }
if let Some(rest) = path.strip_prefix("attachments/") {
// Expect exactly: <slug>/<item-id>/<att-id>.enc → three segments.
let segments: Vec<&str> = rest.split('/').collect();
if segments.len() != 3 {
return PathClass::Rejected(
"attachments path must be attachments/<slug>/<item-id>/<att-id>.enc".to_string());
}
let slug = segments[0];
if slug.is_empty() {
return PathClass::Rejected("empty collection slug in attachments path".to_string());
}
if slug.contains('.') {
return PathClass::Rejected(format!("invalid collection slug: {:?}", slug));
}
return PathClass::Item { collection: slug.to_string() };
}
PathClass::Unrestricted PathClass::Unrestricted
} }

View File

@@ -79,3 +79,43 @@ fn extract_schema_version_errors_on_missing_field() {
fn extract_schema_version_errors_on_garbage() { fn extract_schema_version_errors_on_garbage() {
assert!(extract_schema_version("not json").is_err()); assert!(extract_schema_version("not json").is_err());
} }
#[test]
fn attachment_path_is_collection_scoped() {
assert_eq!(
classify_path("attachments/prod/a1b2c3d4e5f6a1b2/0011223344556677.enc"),
PathClass::Item { collection: "prod".to_string() }
);
}
#[test]
fn attachment_wrong_segment_count_is_rejected() {
assert_eq!(
classify_path("attachments/prod/onlytwo.enc"),
PathClass::Rejected("attachments path must be attachments/<slug>/<item-id>/<att-id>.enc".to_string())
);
}
#[test]
fn attachment_empty_or_dotted_slug_is_rejected() {
assert!(matches!(classify_path("attachments//item/att.enc"), PathClass::Rejected(_)));
assert!(matches!(classify_path("attachments/../item/att.enc"), PathClass::Rejected(_)));
}
#[test]
fn attachments_prefix_alone_is_rejected_not_unrestricted() {
// `attachments/` with no slug/item/att segments must be Rejected, NOT fall
// through to Unrestricted — that fall-through was the authz gap this closes.
assert!(matches!(classify_path("attachments/"), PathClass::Rejected(_)));
}
#[test]
fn attachment_att_id_segment_may_contain_dots() {
// The `.`-free guard applies to the slug (segment[0]) ONLY; the att-id segment
// legitimately carries `.enc` and is unharmed by additional dots — proving the
// guard is not a blanket "reject any dotted segment".
assert_eq!(
classify_path("attachments/eng/a1b2c3d4e5f6a1b2/00112233.aux.enc"),
PathClass::Item { collection: "eng".to_string() }
);
}

View File

@@ -111,10 +111,11 @@ before they land.
rejected outright. rejected outright.
2. **Path-level write authorisation** — each modified path is classified by 2. **Path-level write authorisation** — each modified path is classified by
`classify_path` (`crates/relicario-server/src/lib.rs:19`) into `classify_path` (`crates/relicario-server/src/lib.rs:20`) into
`ProtectedJson` (owner/admin write only), `CollectionItem` (the `Protected` (owner/admin write only), `Item { collection }` (the
`items/<slug>/…` prefix; write allowed only if the slug appears in the `items/<slug>/…` or `attachments/<slug>/…` prefix; write allowed only if
signer's `collections` grant array), or `Unrestricted`. The write is the slug appears in the signer's `collections` grant array), or
`Unrestricted`. The write is
authorised if and only if the signer's role and grants satisfy the authorised if and only if the signer's role and grants satisfy the
classification. Item blobs are authorised by the leading path segment classification. Item blobs are authorised by the leading path segment
alone — the ciphertext is never decrypted by the hook. alone — the ciphertext is never decrypted by the hook.
@@ -132,6 +133,21 @@ before they land.
Merge commits are rejected. A genesis commit (no parents) is allowed Merge commits are rejected. A genesis commit (no parents) is allowed
only when it is signed by the sole Owner it introduces. only when it is signed by the sole Owner it introduces.
#### Attachment write authorisation (v0.1.1 fix)
Prior to `relicario-server` v0.1.1, `attachments/…` paths fell through to
`PathClass::Unrestricted` in `classify_path`
(`crates/relicario-server/src/lib.rs:20`). Any member with push access could
write attachment blobs to any collection regardless of their grants. As of
v0.1.1, `attachments/<slug>/<item-id>/<att-id>.enc` is classified as
`PathClass::Item { collection: slug }`, bringing attachment writes under the
same grant check already applied to `items/<slug>/<id>.enc` blobs.
**Deploying this fix requires rebuilding and redeploying the pre-receive hook
on the server.** A server still running a hook built before v0.1.1 continues
to accept attachment pushes from any member; the `Unrestricted` path is only
closed once the updated hook is installed at `<repo>/hooks/pre-receive`.
### Key rotation ### Key rotation
`relicario org rotate-key` generates a fresh 256-bit org master key, `relicario org rotate-key` generates a fresh 256-bit org master key,