Merge feature/plan-4-security-fixes: security fixes + device authentication
This commit is contained in:
@@ -4,4 +4,5 @@ members = [
|
|||||||
"crates/relicario-core",
|
"crates/relicario-core",
|
||||||
"crates/relicario-cli",
|
"crates/relicario-cli",
|
||||||
"crates/relicario-wasm",
|
"crates/relicario-wasm",
|
||||||
|
"crates/relicario-server",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ arboard = "3"
|
|||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
@@ -28,6 +27,7 @@ tar = { version = "0.4", default-features = false }
|
|||||||
clap_complete = "4"
|
clap_complete = "4"
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||||
rqrr = "0.7"
|
rqrr = "0.7"
|
||||||
|
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
assert_cmd = "2"
|
assert_cmd = "2"
|
||||||
|
|||||||
166
crates/relicario-cli/src/device.rs
Normal file
166
crates/relicario-cli/src/device.rs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
//! Local device key storage and git signing configuration.
|
||||||
|
//!
|
||||||
|
//! Keys live under `~/.config/relicario/devices/<device-name>/`:
|
||||||
|
//! signing.key — ed25519 private key (OpenSSH, 0600)
|
||||||
|
//! signing.pub — ed25519 public key (OpenSSH single line)
|
||||||
|
//! deploy.key — ed25519 private key for git push (OpenSSH, 0600)
|
||||||
|
//! deploy.pub — ed25519 public key registered as Gitea deploy key
|
||||||
|
//! gitea_key_id — numeric Gitea deploy key ID for later revocation
|
||||||
|
//!
|
||||||
|
//! The file `~/.config/relicario/devices/current` holds the active device name
|
||||||
|
//! (one plain-text line).
|
||||||
|
|
||||||
|
use std::fs::{self, Permissions};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
/// `~/.config/relicario/devices/`
|
||||||
|
pub fn devices_dir() -> Result<PathBuf> {
|
||||||
|
let config = dirs::config_dir()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no config directory available"))?;
|
||||||
|
Ok(config.join("relicario").join("devices"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `~/.config/relicario/devices/<name>/`
|
||||||
|
pub fn device_dir(name: &str) -> Result<PathBuf> {
|
||||||
|
Ok(devices_dir()?.join(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the current device name from `devices/current`, or `None` if not set.
|
||||||
|
pub fn current_device() -> Result<Option<String>> {
|
||||||
|
let path = devices_dir()?.join("current");
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let name = fs::read_to_string(&path)
|
||||||
|
.context("read current device")?
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if name.is_empty() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Ok(Some(name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the active device name to `devices/current`.
|
||||||
|
pub fn set_current_device(name: &str) -> Result<()> {
|
||||||
|
let dir = devices_dir()?;
|
||||||
|
fs::create_dir_all(&dir).context("create devices dir")?;
|
||||||
|
fs::write(dir.join("current"), format!("{name}\n"))
|
||||||
|
.context("write current device")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store all keys for a device, applying restrictive permissions on private
|
||||||
|
/// key files on Unix.
|
||||||
|
pub fn store_device_keys(
|
||||||
|
name: &str,
|
||||||
|
signing_private: &str,
|
||||||
|
signing_public: &str,
|
||||||
|
deploy_private: &str,
|
||||||
|
deploy_public: &str,
|
||||||
|
gitea_key_id: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
let dir = device_dir(name)?;
|
||||||
|
fs::create_dir_all(&dir).context("create device dir")?;
|
||||||
|
|
||||||
|
fs::write(dir.join("signing.key"), signing_private)
|
||||||
|
.context("write signing.key")?;
|
||||||
|
fs::write(dir.join("signing.pub"), signing_public)
|
||||||
|
.context("write signing.pub")?;
|
||||||
|
fs::write(dir.join("deploy.key"), deploy_private)
|
||||||
|
.context("write deploy.key")?;
|
||||||
|
fs::write(dir.join("deploy.pub"), deploy_public)
|
||||||
|
.context("write deploy.pub")?;
|
||||||
|
fs::write(dir.join("gitea_key_id"), gitea_key_id.to_string())
|
||||||
|
.context("write gitea_key_id")?;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(dir.join("signing.key"), Permissions::from_mode(0o600))
|
||||||
|
.context("chmod signing.key")?;
|
||||||
|
fs::set_permissions(dir.join("deploy.key"), Permissions::from_mode(0o600))
|
||||||
|
.context("chmod deploy.key")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the signing private key for a device.
|
||||||
|
pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
|
||||||
|
let path = device_dir(name)?.join("signing.key");
|
||||||
|
let key = fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("read signing key for device '{name}'"))?;
|
||||||
|
Ok(Zeroizing::new(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the deploy private key for a device.
|
||||||
|
pub fn load_deploy_key(name: &str) -> Result<Zeroizing<String>> {
|
||||||
|
let path = device_dir(name)?.join("deploy.key");
|
||||||
|
let key = fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("read deploy key for device '{name}'"))?;
|
||||||
|
Ok(Zeroizing::new(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the Gitea deploy key ID for a device.
|
||||||
|
pub fn load_gitea_key_id(name: &str) -> Result<u64> {
|
||||||
|
let path = device_dir(name)?.join("gitea_key_id");
|
||||||
|
let id_str = fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("read Gitea key ID for device '{name}'"))?;
|
||||||
|
id_str.trim().parse().context("parse Gitea key ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the local key directory for a device.
|
||||||
|
pub fn delete_device_keys(name: &str) -> Result<()> {
|
||||||
|
let dir = device_dir(name)?;
|
||||||
|
if dir.exists() {
|
||||||
|
fs::remove_dir_all(&dir)
|
||||||
|
.with_context(|| format!("delete device dir for '{name}'"))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure git in `vault_root` to:
|
||||||
|
/// - sign commits with the device's signing key (SSH format)
|
||||||
|
/// - push via SSH using the device's deploy key
|
||||||
|
pub fn configure_git_signing(vault_root: &std::path::Path, name: &str) -> Result<()> {
|
||||||
|
let dir = device_dir(name)?;
|
||||||
|
let signing_key = dir.join("signing.key");
|
||||||
|
let deploy_key = dir.join("deploy.key");
|
||||||
|
|
||||||
|
// gpg.format = ssh so git uses SSH-format signing
|
||||||
|
crate::helpers::git_command(vault_root, &["config", "gpg.format", "ssh"])
|
||||||
|
.status()
|
||||||
|
.context("git config gpg.format")?;
|
||||||
|
|
||||||
|
// user.signingkey = path to the private key file
|
||||||
|
crate::helpers::git_command(
|
||||||
|
vault_root,
|
||||||
|
&["config", "user.signingkey", &signing_key.to_string_lossy()],
|
||||||
|
)
|
||||||
|
.status()
|
||||||
|
.context("git config user.signingkey")?;
|
||||||
|
|
||||||
|
// commit.gpgsign = true
|
||||||
|
crate::helpers::git_command(vault_root, &["config", "commit.gpgsign", "true"])
|
||||||
|
.status()
|
||||||
|
.context("git config commit.gpgsign")?;
|
||||||
|
|
||||||
|
// core.sshCommand — use only the deploy key for push
|
||||||
|
let ssh_cmd = format!(
|
||||||
|
"ssh -i {} -o IdentitiesOnly=yes",
|
||||||
|
deploy_key.display()
|
||||||
|
);
|
||||||
|
crate::helpers::git_command(
|
||||||
|
vault_root,
|
||||||
|
&["config", "core.sshCommand", &ssh_cmd],
|
||||||
|
)
|
||||||
|
.status()
|
||||||
|
.context("git config core.sshCommand")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
114
crates/relicario-cli/src/gitea.rs
Normal file
114
crates/relicario-cli/src/gitea.rs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
//! Gitea API client for deploy key management.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GiteaClient {
|
||||||
|
api_url: String,
|
||||||
|
token: String,
|
||||||
|
owner: String,
|
||||||
|
repo: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct CreateKeyRequest<'a> {
|
||||||
|
title: &'a str,
|
||||||
|
key: &'a str,
|
||||||
|
read_only: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DeployKey {
|
||||||
|
pub id: u64,
|
||||||
|
pub title: String,
|
||||||
|
pub key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GiteaClient {
|
||||||
|
pub fn new(api_url: &str, token: &str, owner: &str, repo: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
api_url: api_url.trim_end_matches('/').to_string(),
|
||||||
|
token: token.to_string(),
|
||||||
|
owner: owner.to_string(),
|
||||||
|
repo: repo.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a deploy key, returning its ID.
|
||||||
|
pub fn create_deploy_key(&self, title: &str, public_key: &str) -> Result<u64> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/repos/{}/{}/keys",
|
||||||
|
self.api_url, self.owner, self.repo
|
||||||
|
);
|
||||||
|
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", format!("token {}", self.token))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(&CreateKeyRequest {
|
||||||
|
title,
|
||||||
|
key: public_key,
|
||||||
|
read_only: false,
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.context("Gitea API request failed")?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().unwrap_or_default();
|
||||||
|
anyhow::bail!("Gitea API error {}: {}", status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let key: DeployKey = resp.json().context("parse deploy key response")?;
|
||||||
|
Ok(key.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a deploy key by ID.
|
||||||
|
pub fn delete_deploy_key(&self, key_id: u64) -> Result<()> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/repos/{}/{}/keys/{}",
|
||||||
|
self.api_url, self.owner, self.repo, key_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.delete(&url)
|
||||||
|
.header("Authorization", format!("token {}", self.token))
|
||||||
|
.send()
|
||||||
|
.context("Gitea API request failed")?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() && resp.status().as_u16() != 404 {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().unwrap_or_default();
|
||||||
|
anyhow::bail!("Gitea API error {}: {}", status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all deploy keys.
|
||||||
|
pub fn list_deploy_keys(&self) -> Result<Vec<DeployKey>> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/repos/{}/{}/keys",
|
||||||
|
self.api_url, self.owner, self.repo
|
||||||
|
);
|
||||||
|
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("token {}", self.token))
|
||||||
|
.send()
|
||||||
|
.context("Gitea API request failed")?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().unwrap_or_default();
|
||||||
|
anyhow::bail!("Gitea API error {}: {}", status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let keys: Vec<DeployKey> = resp.json().context("parse deploy keys response")?;
|
||||||
|
Ok(keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,6 +115,21 @@ pub fn write_groups_cache(
|
|||||||
std::fs::write(path, body)
|
std::fs::write(path, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sanitize a string for use in a git commit message subject line.
|
||||||
|
///
|
||||||
|
/// Removes all Unicode control characters (U+0000–U+001F, U+007F, and higher
|
||||||
|
/// control planes) so that newlines and escape sequences cannot corrupt `git
|
||||||
|
/// log` output. Truncates to 50 characters so the subject line stays within
|
||||||
|
/// the conventional limit.
|
||||||
|
///
|
||||||
|
/// Audit I1: item titles are user-supplied and may contain arbitrary bytes.
|
||||||
|
pub fn sanitize_for_commit(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
.filter(|c| !c.is_control())
|
||||||
|
.take(50)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Decode a QR image at `path`. Returns the otpauth secret (base32) if the
|
/// Decode a QR image at `path`. Returns the otpauth secret (base32) if the
|
||||||
/// QR decodes to an `otpauth://...` URI with a `secret` query param.
|
/// QR decodes to an `otpauth://...` URI with a `secret` query param.
|
||||||
pub fn decode_totp_qr(path: &std::path::Path) -> anyhow::Result<String> {
|
pub fn decode_totp_qr(path: &std::path::Path) -> anyhow::Result<String> {
|
||||||
@@ -179,6 +194,29 @@ mod tests {
|
|||||||
assert_eq!(iso8601(1_776_556_800), "2026-04-19T00:00:00Z");
|
assert_eq!(iso8601(1_776_556_800), "2026-04-19T00:00:00Z");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_for_commit_strips_control_chars() {
|
||||||
|
assert_eq!(sanitize_for_commit("line1\nline2"), "line1line2");
|
||||||
|
assert_eq!(sanitize_for_commit("a\tb"), "ab");
|
||||||
|
assert_eq!(sanitize_for_commit("normal"), "normal");
|
||||||
|
assert_eq!(sanitize_for_commit("cr\r\nline"), "crline");
|
||||||
|
// ESC (U+001B) is control and gets stripped; bracket sequences are printable
|
||||||
|
assert_eq!(sanitize_for_commit("\x1b[31mred\x1b[0m"), "[31mred[0m");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_for_commit_truncates_to_50() {
|
||||||
|
let long = "a".repeat(60);
|
||||||
|
assert_eq!(sanitize_for_commit(&long).len(), 50);
|
||||||
|
assert_eq!(sanitize_for_commit(&long), "a".repeat(50));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_for_commit_allows_unicode() {
|
||||||
|
assert_eq!(sanitize_for_commit("cafe\u{0301}"), "cafe\u{0301}");
|
||||||
|
assert_eq!(sanitize_for_commit("emoji \u{1F4AA}"), "emoji \u{1F4AA}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn humanize_age_buckets() {
|
fn humanize_age_buckets() {
|
||||||
assert_eq!(humanize_age(0), "just now");
|
assert_eq!(humanize_age(0), "just now");
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
//!
|
//!
|
||||||
//! See module docs for the unlock flow and vault layout.
|
//! See module docs for the unlock flow and vault layout.
|
||||||
|
|
||||||
|
mod device;
|
||||||
|
mod gitea;
|
||||||
mod helpers;
|
mod helpers;
|
||||||
mod session;
|
mod session;
|
||||||
|
|
||||||
@@ -158,15 +160,9 @@ enum Commands {
|
|||||||
/// Sync with the git remote (pull --rebase + push).
|
/// Sync with the git remote (pull --rebase + push).
|
||||||
Sync,
|
Sync,
|
||||||
|
|
||||||
/// Print a summary of the vault: items, attachments, devices, last commit.
|
/// Print a summary of the vault: items, attachments, last commit.
|
||||||
Status,
|
Status,
|
||||||
|
|
||||||
/// Device management.
|
|
||||||
Device {
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: DeviceAction,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Lock the vault (no-op in CLI; present for UX parity with the extension).
|
/// Lock the vault (no-op in CLI; present for UX parity with the extension).
|
||||||
Lock,
|
Lock,
|
||||||
|
|
||||||
@@ -194,6 +190,12 @@ enum Commands {
|
|||||||
/// Passphrase to score, or `-` to read from stdin.
|
/// Passphrase to score, or `-` to read from stdin.
|
||||||
passphrase: String,
|
passphrase: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Manage registered devices (signing keys + deploy keys).
|
||||||
|
Device {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: DeviceAction,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
@@ -312,13 +314,6 @@ enum SettingsAction {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
|
||||||
enum DeviceAction {
|
|
||||||
Add { #[arg(long)] name: String },
|
|
||||||
List,
|
|
||||||
Revoke { name: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum BackupAction {
|
enum BackupAction {
|
||||||
/// Pack the local vault into a single encrypted `.relbak` file.
|
/// Pack the local vault into a single encrypted `.relbak` file.
|
||||||
@@ -360,6 +355,54 @@ enum ImportAction {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum DeviceAction {
|
||||||
|
/// Register this machine as a new device.
|
||||||
|
///
|
||||||
|
/// Generates two ed25519 keypairs: one for signing commits, one for push
|
||||||
|
/// access (deploy key). The deploy public key is registered via the Gitea
|
||||||
|
/// API. Both private keys are stored locally in
|
||||||
|
/// `~/.config/relicario/devices/<name>/`. The vault's `.relicario/devices.json`
|
||||||
|
/// is updated and committed.
|
||||||
|
///
|
||||||
|
/// Required environment variables (or flags):
|
||||||
|
/// RELICARIO_GITEA_URL — e.g. https://git.example.com
|
||||||
|
/// RELICARIO_GITEA_TOKEN — personal access token with repo write access
|
||||||
|
/// RELICARIO_GITEA_OWNER — repository owner
|
||||||
|
/// RELICARIO_GITEA_REPO — repository name
|
||||||
|
Add {
|
||||||
|
/// Human-readable name for this device (e.g. "laptop-2026").
|
||||||
|
#[arg(long)]
|
||||||
|
name: String,
|
||||||
|
/// Gitea API base URL (overrides RELICARIO_GITEA_URL).
|
||||||
|
#[arg(long)]
|
||||||
|
gitea_url: Option<String>,
|
||||||
|
/// Gitea personal access token (overrides RELICARIO_GITEA_TOKEN).
|
||||||
|
#[arg(long)]
|
||||||
|
gitea_token: Option<String>,
|
||||||
|
/// Gitea repository owner (overrides RELICARIO_GITEA_OWNER).
|
||||||
|
#[arg(long)]
|
||||||
|
owner: Option<String>,
|
||||||
|
/// Gitea repository name (overrides RELICARIO_GITEA_REPO).
|
||||||
|
#[arg(long)]
|
||||||
|
repo: Option<String>,
|
||||||
|
/// Skip Gitea API registration (useful when the remote is not Gitea).
|
||||||
|
#[arg(long)]
|
||||||
|
no_gitea: bool,
|
||||||
|
},
|
||||||
|
/// Revoke a registered device.
|
||||||
|
///
|
||||||
|
/// Removes the device from `devices.json`, adds it to `revoked.json`,
|
||||||
|
/// deletes the deploy key from Gitea, and commits the change.
|
||||||
|
Revoke {
|
||||||
|
/// Name of the device to revoke.
|
||||||
|
#[arg(long)]
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
/// List registered devices.
|
||||||
|
List,
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
match cli.command {
|
match cli.command {
|
||||||
@@ -385,7 +428,6 @@ fn main() -> Result<()> {
|
|||||||
Commands::Settings { action } => cmd_settings(action),
|
Commands::Settings { action } => cmd_settings(action),
|
||||||
Commands::Sync => cmd_sync(),
|
Commands::Sync => cmd_sync(),
|
||||||
Commands::Status => cmd_status(),
|
Commands::Status => cmd_status(),
|
||||||
Commands::Device { action } => cmd_device(action),
|
|
||||||
Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) }
|
Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) }
|
||||||
Commands::Completions { shell } => {
|
Commands::Completions { shell } => {
|
||||||
let mut cmd = Cli::command();
|
let mut cmd = Cli::command();
|
||||||
@@ -393,6 +435,7 @@ fn main() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Commands::Rate { passphrase } => cmd_rate(passphrase),
|
Commands::Rate { passphrase } => cmd_rate(passphrase),
|
||||||
|
Commands::Device { action } => cmd_device(action),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,11 +457,41 @@ fn refresh_groups_cache(vault_dir: &std::path::Path, manifest: &relicario_core::
|
|||||||
let _ = helpers::write_groups_cache(vault_dir, &set);
|
let _ = helpers::write_groups_cache(vault_dir, &set);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check for test passphrase override (debug builds only; stripped from release).
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
pub(crate) fn test_passphrase_override() -> Option<String> {
|
||||||
|
std::env::var("RELICARIO_TEST_PASSPHRASE").ok()
|
||||||
|
}
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
pub(crate) fn test_passphrase_override() -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for test item secret override (debug builds only; stripped from release).
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
fn test_item_secret_override() -> Option<String> {
|
||||||
|
std::env::var("RELICARIO_TEST_ITEM_SECRET").ok()
|
||||||
|
}
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
fn test_item_secret_override() -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for test backup passphrase override (debug builds only; stripped from release).
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
fn test_backup_passphrase_override() -> Option<String> {
|
||||||
|
std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE").ok()
|
||||||
|
}
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
fn test_backup_passphrase_override() -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
|
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
|
||||||
/// for integration-test use (rpassword reads /dev/tty by default, which is
|
/// for integration-test use (rpassword reads /dev/tty by default, which is
|
||||||
/// unavailable in assert_cmd-spawned children).
|
/// unavailable in assert_cmd-spawned children).
|
||||||
fn prompt_secret(label: &str) -> Result<String> {
|
fn prompt_secret(label: &str) -> Result<String> {
|
||||||
if let Ok(s) = std::env::var("RELICARIO_TEST_ITEM_SECRET") {
|
if let Some(s) = test_item_secret_override() {
|
||||||
return Ok(s);
|
return Ok(s);
|
||||||
}
|
}
|
||||||
rpassword::prompt_password(label).map_err(Into::into)
|
rpassword::prompt_password(label).map_err(Into::into)
|
||||||
@@ -442,12 +515,12 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
|||||||
// Passphrase with strength gate (audit H3).
|
// Passphrase with strength gate (audit H3).
|
||||||
// RELICARIO_TEST_PASSPHRASE is a test-only escape hatch that bypasses the
|
// RELICARIO_TEST_PASSPHRASE is a test-only escape hatch that bypasses the
|
||||||
// TTY prompt so integration tests can run without a real TTY.
|
// TTY prompt so integration tests can run without a real TTY.
|
||||||
let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_PASSPHRASE") {
|
let passphrase = if let Some(p) = test_passphrase_override() {
|
||||||
Zeroizing::new(p)
|
Zeroizing::new(p)
|
||||||
} else {
|
} else {
|
||||||
Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?)
|
Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?)
|
||||||
};
|
};
|
||||||
let confirm = if std::env::var_os("RELICARIO_TEST_PASSPHRASE").is_some() {
|
let confirm = if test_passphrase_override().is_some() {
|
||||||
passphrase.clone()
|
passphrase.clone()
|
||||||
} else {
|
} else {
|
||||||
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
|
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
|
||||||
@@ -497,8 +570,6 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
|||||||
salt_path: ".relicario/salt".into(),
|
salt_path: ".relicario/salt".into(),
|
||||||
})?,
|
})?,
|
||||||
)?;
|
)?;
|
||||||
fs::write(relicario_dir.join("devices.json"), b"[]")?;
|
|
||||||
|
|
||||||
let manifest = Manifest::new();
|
let manifest = Manifest::new();
|
||||||
fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?;
|
fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?;
|
||||||
let settings = VaultSettings::default();
|
let settings = VaultSettings::default();
|
||||||
@@ -515,7 +586,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
|||||||
let status = crate::helpers::git_command(&root, &["init"]).status()?;
|
let status = crate::helpers::git_command(&root, &["init"]).status()?;
|
||||||
if !status.success() { anyhow::bail!("git init failed"); }
|
if !status.success() { anyhow::bail!("git init failed"); }
|
||||||
let _ = crate::helpers::git_command(&root, &[
|
let _ = crate::helpers::git_command(&root, &[
|
||||||
"add", ".gitignore", ".relicario/params.json", ".relicario/devices.json",
|
"add", ".gitignore", ".relicario/params.json",
|
||||||
".relicario/salt", "manifest.enc", "settings.enc",
|
".relicario/salt", "manifest.enc", "settings.enc",
|
||||||
]).status()?;
|
]).status()?;
|
||||||
let status = crate::helpers::git_command(&root, &[
|
let status = crate::helpers::git_command(&root, &[
|
||||||
@@ -562,7 +633,7 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
|||||||
paths.push(format!("attachments/{}/{}.enc", item.id.as_str(), att.id.as_str()));
|
paths.push(format!("attachments/{}/{}.enc", item.id.as_str(), att.id.as_str()));
|
||||||
}
|
}
|
||||||
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
|
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
|
||||||
commit_paths(&vault, &format!("add: {} ({})", item.title, item.id.as_str()), &path_refs)?;
|
commit_paths(&vault, &format!("add: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), &path_refs)?;
|
||||||
|
|
||||||
eprintln!("Added: {} (id={})", item.title, item.id.as_str());
|
eprintln!("Added: {} (id={})", item.title, item.id.as_str());
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1107,7 +1178,7 @@ fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
|||||||
manifest.upsert(&item);
|
manifest.upsert(&item);
|
||||||
vault.save_manifest(&manifest)?;
|
vault.save_manifest(&manifest)?;
|
||||||
refresh_groups_cache(vault.root(), &manifest);
|
refresh_groups_cache(vault.root(), &manifest);
|
||||||
commit_paths(&vault, &format!("edit: {} ({})", item.title, item.id.as_str()),
|
commit_paths(&vault, &format!("edit: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||||
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||||
eprintln!("Updated {}", item.id.as_str());
|
eprintln!("Updated {}", item.id.as_str());
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1324,7 +1395,7 @@ fn cmd_rm(query: String) -> Result<()> {
|
|||||||
manifest.upsert(&item);
|
manifest.upsert(&item);
|
||||||
vault.save_manifest(&manifest)?;
|
vault.save_manifest(&manifest)?;
|
||||||
refresh_groups_cache(vault.root(), &manifest);
|
refresh_groups_cache(vault.root(), &manifest);
|
||||||
commit_paths(&vault, &format!("trash: {} ({})", item.title, item.id.as_str()),
|
commit_paths(&vault, &format!("trash: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||||
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||||
eprintln!("Moved to trash: {}", item.title);
|
eprintln!("Moved to trash: {}", item.title);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1342,7 +1413,7 @@ fn cmd_restore(query: String) -> Result<()> {
|
|||||||
manifest.upsert(&item);
|
manifest.upsert(&item);
|
||||||
vault.save_manifest(&manifest)?;
|
vault.save_manifest(&manifest)?;
|
||||||
refresh_groups_cache(vault.root(), &manifest);
|
refresh_groups_cache(vault.root(), &manifest);
|
||||||
commit_paths(&vault, &format!("restore: {} ({})", item.title, item.id.as_str()),
|
commit_paths(&vault, &format!("restore: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||||
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||||
eprintln!("Restored: {}", item.title);
|
eprintln!("Restored: {}", item.title);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1422,12 +1493,12 @@ fn cmd_backup_export(
|
|||||||
let root = crate::helpers::vault_dir()?;
|
let root = crate::helpers::vault_dir()?;
|
||||||
|
|
||||||
// Backup passphrase — prompt twice, gate on zxcvbn (audit H3).
|
// Backup passphrase — prompt twice, gate on zxcvbn (audit H3).
|
||||||
let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE") {
|
let passphrase = if let Some(p) = test_backup_passphrase_override() {
|
||||||
Zeroizing::new(p)
|
Zeroizing::new(p)
|
||||||
} else {
|
} else {
|
||||||
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
|
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
|
||||||
};
|
};
|
||||||
let confirm = if std::env::var_os("RELICARIO_TEST_BACKUP_PASSPHRASE").is_some() {
|
let confirm = if test_backup_passphrase_override().is_some() {
|
||||||
passphrase.clone()
|
passphrase.clone()
|
||||||
} else {
|
} else {
|
||||||
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
|
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
|
||||||
@@ -1444,8 +1515,11 @@ fn cmd_backup_export(
|
|||||||
.with_context(|| "failed to read .relicario/salt")?;
|
.with_context(|| "failed to read .relicario/salt")?;
|
||||||
let params_json = fs::read_to_string(root.join(".relicario").join("params.json"))
|
let params_json = fs::read_to_string(root.join(".relicario").join("params.json"))
|
||||||
.with_context(|| "failed to read .relicario/params.json")?;
|
.with_context(|| "failed to read .relicario/params.json")?;
|
||||||
|
// devices.json was removed in the B1 security audit fix; fall back to
|
||||||
|
// an empty array so backups of post-B1 vaults still pack cleanly.
|
||||||
|
// Task 12 will remove the devices field from the backup format entirely.
|
||||||
let devices_json = fs::read_to_string(root.join(".relicario").join("devices.json"))
|
let devices_json = fs::read_to_string(root.join(".relicario").join("devices.json"))
|
||||||
.with_context(|| "failed to read .relicario/devices.json")?;
|
.unwrap_or_else(|_| "[]".to_string());
|
||||||
let manifest_enc = fs::read(root.join("manifest.enc"))
|
let manifest_enc = fs::read(root.join("manifest.enc"))
|
||||||
.with_context(|| "failed to read manifest.enc")?;
|
.with_context(|| "failed to read manifest.enc")?;
|
||||||
let settings_enc = fs::read(root.join("settings.enc"))
|
let settings_enc = fs::read(root.join("settings.enc"))
|
||||||
@@ -1569,6 +1643,7 @@ fn tar_directory(dir: &std::path::Path) -> Result<Vec<u8>> {
|
|||||||
fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
|
fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use relicario_core::backup;
|
use relicario_core::backup;
|
||||||
|
use relicario_core::{ItemId, AttachmentId};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
let target = if target.is_absolute() {
|
let target = if target.is_absolute() {
|
||||||
@@ -1591,7 +1666,7 @@ fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
|
|||||||
.with_context(|| format!("failed to read backup file {}", input.display()))?;
|
.with_context(|| format!("failed to read backup file {}", input.display()))?;
|
||||||
|
|
||||||
// Backup passphrase prompt.
|
// Backup passphrase prompt.
|
||||||
let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE") {
|
let passphrase = if let Some(p) = test_backup_passphrase_override() {
|
||||||
Zeroizing::new(p)
|
Zeroizing::new(p)
|
||||||
} else {
|
} else {
|
||||||
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
|
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
|
||||||
@@ -1617,9 +1692,18 @@ fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
|
|||||||
fs::write(target.join("settings.enc"), &unpacked.settings_enc)?;
|
fs::write(target.join("settings.enc"), &unpacked.settings_enc)?;
|
||||||
|
|
||||||
for item in &unpacked.items {
|
for item in &unpacked.items {
|
||||||
|
let item_id = ItemId(item.id.clone());
|
||||||
|
if !item_id.is_valid() {
|
||||||
|
anyhow::bail!("invalid item ID in backup: {} (path traversal blocked)", item.id);
|
||||||
|
}
|
||||||
fs::write(target.join("items").join(format!("{}.enc", item.id)), &item.ciphertext)?;
|
fs::write(target.join("items").join(format!("{}.enc", item.id)), &item.ciphertext)?;
|
||||||
}
|
}
|
||||||
for a in &unpacked.attachments {
|
for a in &unpacked.attachments {
|
||||||
|
let item_id = ItemId(a.item_id.clone());
|
||||||
|
let att_id = AttachmentId(a.attachment_id.clone());
|
||||||
|
if !item_id.is_valid() || !att_id.is_valid() {
|
||||||
|
anyhow::bail!("invalid attachment ID in backup (path traversal blocked)");
|
||||||
|
}
|
||||||
let dir = target.join("attachments").join(&a.item_id);
|
let dir = target.join("attachments").join(&a.item_id);
|
||||||
fs::create_dir_all(&dir)?;
|
fs::create_dir_all(&dir)?;
|
||||||
fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?;
|
fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?;
|
||||||
@@ -1799,6 +1883,28 @@ fn cmd_attach(query: String, file: PathBuf) -> Result<()> {
|
|||||||
|
|
||||||
let bytes = fs::read(&file)
|
let bytes = fs::read(&file)
|
||||||
.with_context(|| format!("failed to read {}", file.display()))?;
|
.with_context(|| format!("failed to read {}", file.display()))?;
|
||||||
|
|
||||||
|
// Check per-vault total attachment bytes cap (audit I3).
|
||||||
|
let current_total: u64 = manifest.items.values()
|
||||||
|
.flat_map(|e| &e.attachment_summaries)
|
||||||
|
.map(|s| s.size)
|
||||||
|
.sum();
|
||||||
|
let new_size = bytes.len() as u64;
|
||||||
|
let hard_cap = caps.per_vault_hard_cap_bytes;
|
||||||
|
let soft_cap = caps.per_vault_soft_cap_bytes;
|
||||||
|
if current_total + new_size > hard_cap {
|
||||||
|
anyhow::bail!(
|
||||||
|
"attachment would exceed vault hard cap ({} + {} > {} bytes)",
|
||||||
|
current_total, new_size, hard_cap
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if current_total + new_size > soft_cap {
|
||||||
|
eprintln!(
|
||||||
|
"warning: vault attachments will exceed soft cap ({} bytes)",
|
||||||
|
soft_cap
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
|
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
|
||||||
|
|
||||||
let filename = file.file_name()
|
let filename = file.file_name()
|
||||||
@@ -1831,7 +1937,9 @@ fn cmd_attach(query: String, file: PathBuf) -> Result<()> {
|
|||||||
];
|
];
|
||||||
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
|
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
|
||||||
commit_paths(&vault, &format!("attach: {} → {} ({})",
|
commit_paths(&vault, &format!("attach: {} → {} ({})",
|
||||||
file.display(), item.title, item.id.as_str()), &path_refs)?;
|
crate::helpers::sanitize_for_commit(&file.display().to_string()),
|
||||||
|
crate::helpers::sanitize_for_commit(&item.title),
|
||||||
|
item.id.as_str()), &path_refs)?;
|
||||||
eprintln!("Attached {} to {} (aid={})", file.display(), item.title, enc.id.as_str());
|
eprintln!("Attached {} to {} (aid={})", file.display(), item.title, enc.id.as_str());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1914,7 +2022,7 @@ fn cmd_detach(query: String, aid: String) -> Result<()> {
|
|||||||
let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str());
|
let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str());
|
||||||
commit_paths(
|
commit_paths(
|
||||||
&vault,
|
&vault,
|
||||||
&format!("detach: {} from {} ({})", removed.filename, item.title, item.id.as_str()),
|
&format!("detach: {} from {} ({})", crate::helpers::sanitize_for_commit(&removed.filename), crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||||
&[&item_path, "manifest.enc", &blob_relpath],
|
&[&item_path, "manifest.enc", &blob_relpath],
|
||||||
)?;
|
)?;
|
||||||
eprintln!("Detached {} (aid={}) from {}", removed.filename, aid, item.title);
|
eprintln!("Detached {} (aid={}) from {}", removed.filename, aid, item.title);
|
||||||
@@ -2088,8 +2196,6 @@ fn cmd_sync() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_status() -> Result<()> {
|
fn cmd_status() -> Result<()> {
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
let root = vault.root().to_path_buf();
|
let root = vault.root().to_path_buf();
|
||||||
let manifest = vault.load_manifest()?;
|
let manifest = vault.load_manifest()?;
|
||||||
@@ -2102,16 +2208,6 @@ fn cmd_status() -> Result<()> {
|
|||||||
.flat_map(|e| e.attachment_summaries.iter())
|
.flat_map(|e| e.attachment_summaries.iter())
|
||||||
.fold((0u64, 0u64), |(c, b), s| (c + 1, b + s.size));
|
.fold((0u64, 0u64), |(c, b), s| (c + 1, b + s.size));
|
||||||
|
|
||||||
// devices.json — count entries; missing/empty → 0.
|
|
||||||
let devices_path = root.join(".relicario").join("devices.json");
|
|
||||||
let device_count = match fs::read(&devices_path) {
|
|
||||||
Ok(bytes) => serde_json::from_slice::<serde_json::Value>(&bytes)
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.as_array().map(|a| a.len()))
|
|
||||||
.unwrap_or(0),
|
|
||||||
Err(_) => 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
let last_commit = crate::helpers::git_command(&root, &[
|
let last_commit = crate::helpers::git_command(&root, &[
|
||||||
"log", "-1", "--pretty=format:%h %s",
|
"log", "-1", "--pretty=format:%h %s",
|
||||||
]).output()
|
]).output()
|
||||||
@@ -2143,83 +2239,10 @@ fn cmd_status() -> Result<()> {
|
|||||||
println!("Vault: {}", root.display());
|
println!("Vault: {}", root.display());
|
||||||
println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)");
|
println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)");
|
||||||
println!("Attachments: {attachment_count} ({attachment_bytes} bytes)");
|
println!("Attachments: {attachment_count} ({attachment_bytes} bytes)");
|
||||||
println!("Devices: {device_count}");
|
|
||||||
println!("Last commit: {last_commit}");
|
println!("Last commit: {last_commit}");
|
||||||
println!("Last export: {last_backup_str}");
|
println!("Last export: {last_backup_str}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
fn cmd_device(action: DeviceAction) -> Result<()> {
|
|
||||||
use std::fs;
|
|
||||||
use ed25519_dalek::SigningKey;
|
|
||||||
use rand::rngs::OsRng;
|
|
||||||
|
|
||||||
let root = crate::helpers::vault_dir()?;
|
|
||||||
let devices_path = root.join(".relicario").join("devices.json");
|
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
|
||||||
struct DeviceEntry { name: String, public_key: String }
|
|
||||||
|
|
||||||
match action {
|
|
||||||
DeviceAction::Add { name } => {
|
|
||||||
let mut existing: Vec<DeviceEntry> =
|
|
||||||
serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default();
|
|
||||||
if existing.iter().any(|d| d.name == name) {
|
|
||||||
anyhow::bail!("device `{name}` already exists");
|
|
||||||
}
|
|
||||||
let signing = SigningKey::generate(&mut OsRng);
|
|
||||||
let verifying = signing.verifying_key();
|
|
||||||
let pubkey_hex = hex::encode(verifying.to_bytes());
|
|
||||||
|
|
||||||
existing.push(DeviceEntry { name: name.clone(), public_key: pubkey_hex.clone() });
|
|
||||||
fs::write(&devices_path, serde_json::to_string_pretty(&existing)?)?;
|
|
||||||
|
|
||||||
let cfg_dir = dirs::config_dir()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("no config dir"))?
|
|
||||||
.join("relicario").join("devices");
|
|
||||||
fs::create_dir_all(&cfg_dir)?;
|
|
||||||
let key_path = cfg_dir.join(format!("{name}.key"));
|
|
||||||
fs::write(&key_path, signing.to_bytes())?;
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let status = crate::helpers::git_command(&root,
|
|
||||||
&["add", ".relicario/devices.json"]).status()?;
|
|
||||||
if !status.success() { anyhow::bail!("git add failed"); }
|
|
||||||
let status = crate::helpers::git_command(&root,
|
|
||||||
&["commit", "-m", &format!("device: add {name}")]).status()?;
|
|
||||||
if !status.success() { anyhow::bail!("git commit failed"); }
|
|
||||||
eprintln!("Added device `{name}` (pubkey: {pubkey_hex})");
|
|
||||||
}
|
|
||||||
DeviceAction::List => {
|
|
||||||
let existing: Vec<DeviceEntry> =
|
|
||||||
serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default();
|
|
||||||
if existing.is_empty() { eprintln!("(no devices)"); return Ok(()); }
|
|
||||||
for d in existing {
|
|
||||||
println!("{:<20} {}", d.name, d.public_key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DeviceAction::Revoke { name } => {
|
|
||||||
let mut existing: Vec<DeviceEntry> =
|
|
||||||
serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default();
|
|
||||||
let before = existing.len();
|
|
||||||
existing.retain(|d| d.name != name);
|
|
||||||
if existing.len() == before { anyhow::bail!("device `{name}` not found"); }
|
|
||||||
fs::write(&devices_path, serde_json::to_string_pretty(&existing)?)?;
|
|
||||||
let status = crate::helpers::git_command(&root,
|
|
||||||
&["add", ".relicario/devices.json"]).status()?;
|
|
||||||
if !status.success() { anyhow::bail!("git add failed"); }
|
|
||||||
let status = crate::helpers::git_command(&root,
|
|
||||||
&["commit", "-m", &format!("device: revoke {name}")]).status()?;
|
|
||||||
if !status.success() { anyhow::bail!("git commit failed"); }
|
|
||||||
eprintln!("Revoked device `{name}`");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
struct ParamsFile {
|
struct ParamsFile {
|
||||||
format_version: u32,
|
format_version: u32,
|
||||||
@@ -2261,3 +2284,254 @@ fn cmd_rate(passphrase: String) -> Result<()> {
|
|||||||
println!("note: init requires score ≥ 3 (see `relicario init`)");
|
println!("note: init requires score ≥ 3 (see `relicario init`)");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Device management ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Build a `GiteaClient` from flags or environment variables.
|
||||||
|
fn load_gitea_client(
|
||||||
|
gitea_url: Option<String>,
|
||||||
|
gitea_token: Option<String>,
|
||||||
|
owner: Option<String>,
|
||||||
|
repo: Option<String>,
|
||||||
|
) -> Result<crate::gitea::GiteaClient> {
|
||||||
|
let url = gitea_url
|
||||||
|
.or_else(|| std::env::var("RELICARIO_GITEA_URL").ok())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!(
|
||||||
|
"Gitea URL required — pass --gitea-url or set RELICARIO_GITEA_URL"
|
||||||
|
))?;
|
||||||
|
let token = gitea_token
|
||||||
|
.or_else(|| std::env::var("RELICARIO_GITEA_TOKEN").ok())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!(
|
||||||
|
"Gitea token required — pass --gitea-token or set RELICARIO_GITEA_TOKEN"
|
||||||
|
))?;
|
||||||
|
let owner = owner
|
||||||
|
.or_else(|| std::env::var("RELICARIO_GITEA_OWNER").ok())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!(
|
||||||
|
"Gitea owner required — pass --owner or set RELICARIO_GITEA_OWNER"
|
||||||
|
))?;
|
||||||
|
let repo = repo
|
||||||
|
.or_else(|| std::env::var("RELICARIO_GITEA_REPO").ok())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!(
|
||||||
|
"Gitea repo required — pass --repo or set RELICARIO_GITEA_REPO"
|
||||||
|
))?;
|
||||||
|
Ok(crate::gitea::GiteaClient::new(&url, &token, &owner, &repo))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_device(action: DeviceAction) -> Result<()> {
|
||||||
|
use std::fs;
|
||||||
|
use relicario_core::device::{DeviceEntry, RevokedEntry, generate_keypair};
|
||||||
|
|
||||||
|
let root = crate::helpers::vault_dir()?;
|
||||||
|
let relicario_dir = root.join(".relicario");
|
||||||
|
let devices_path = relicario_dir.join("devices.json");
|
||||||
|
|
||||||
|
match action {
|
||||||
|
DeviceAction::Add { name, gitea_url, gitea_token, owner, repo, no_gitea } => {
|
||||||
|
// Guard: don't overwrite an already-registered device name.
|
||||||
|
let existing: Vec<DeviceEntry> = fs::read(&devices_path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if existing.iter().any(|d| d.name == name) {
|
||||||
|
anyhow::bail!("a device named '{}' is already registered", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("Generating signing keypair...");
|
||||||
|
let (signing_priv, signing_pub) = generate_keypair()
|
||||||
|
.map_err(|e| anyhow::anyhow!("generate signing keypair: {e}"))?;
|
||||||
|
|
||||||
|
eprintln!("Generating deploy keypair...");
|
||||||
|
let (deploy_priv, deploy_pub) = generate_keypair()
|
||||||
|
.map_err(|e| anyhow::anyhow!("generate deploy keypair: {e}"))?;
|
||||||
|
|
||||||
|
// Optionally register deploy key with Gitea.
|
||||||
|
let gitea_key_id: u64 = if no_gitea {
|
||||||
|
eprintln!("Skipping Gitea deploy key registration (--no-gitea).");
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
let client = load_gitea_client(gitea_url, gitea_token, owner, repo)?;
|
||||||
|
let key_title = format!("relicario-{}", name);
|
||||||
|
eprintln!("Registering deploy key '{}' with Gitea...", key_title);
|
||||||
|
client.create_deploy_key(&key_title, &deploy_pub)?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store keys locally with proper permissions.
|
||||||
|
crate::device::store_device_keys(
|
||||||
|
&name,
|
||||||
|
&signing_priv,
|
||||||
|
&signing_pub,
|
||||||
|
&deploy_priv,
|
||||||
|
&deploy_pub,
|
||||||
|
gitea_key_id,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Mark as current device.
|
||||||
|
crate::device::set_current_device(&name)?;
|
||||||
|
|
||||||
|
// Configure git signing + SSH deploy key in the vault repo.
|
||||||
|
crate::device::configure_git_signing(&root, &name)?;
|
||||||
|
|
||||||
|
// Update devices.json.
|
||||||
|
let current_name = name.clone();
|
||||||
|
let mut devices = existing;
|
||||||
|
devices.push(DeviceEntry {
|
||||||
|
name: name.clone(),
|
||||||
|
public_key: signing_pub.clone(),
|
||||||
|
added_at: relicario_core::now_unix(),
|
||||||
|
added_by: current_name,
|
||||||
|
});
|
||||||
|
fs::create_dir_all(&relicario_dir)?;
|
||||||
|
fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?;
|
||||||
|
|
||||||
|
// Commit the update.
|
||||||
|
let status = crate::helpers::git_command(
|
||||||
|
&root,
|
||||||
|
&["add", ".relicario/devices.json"],
|
||||||
|
)
|
||||||
|
.status()?;
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("git add .relicario/devices.json failed");
|
||||||
|
}
|
||||||
|
let msg = format!("device: register {}", name);
|
||||||
|
let status = crate::helpers::git_command(&root, &["commit", "-m", &msg])
|
||||||
|
.status()?;
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("git commit failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("Device '{}' registered.", name);
|
||||||
|
eprintln!("Signing public key:");
|
||||||
|
eprintln!(" {}", signing_pub);
|
||||||
|
if gitea_key_id != 0 {
|
||||||
|
eprintln!("Gitea deploy key ID: {}", gitea_key_id);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceAction::Revoke { name } => {
|
||||||
|
// Guard: refuse to revoke the currently active device (would lock
|
||||||
|
// the user out). They must add another device first.
|
||||||
|
if let Some(current) = crate::device::current_device()? {
|
||||||
|
if current == name {
|
||||||
|
anyhow::bail!(
|
||||||
|
"cannot revoke the current device '{}' — you would lose \
|
||||||
|
push access. Register another device first.",
|
||||||
|
name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load devices.json.
|
||||||
|
let mut devices: Vec<DeviceEntry> = fs::read(&devices_path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let device = devices
|
||||||
|
.iter()
|
||||||
|
.find(|d| d.name == name)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("device '{}' not found", name))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
// Remove from devices.json.
|
||||||
|
devices.retain(|d| d.name != name);
|
||||||
|
fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?;
|
||||||
|
|
||||||
|
// Append to revoked.json.
|
||||||
|
let revoked_path = relicario_dir.join("revoked.json");
|
||||||
|
let mut revoked: Vec<RevokedEntry> = fs::read(&revoked_path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let revoked_by = crate::device::current_device()?
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
revoked.push(RevokedEntry {
|
||||||
|
name: name.clone(),
|
||||||
|
public_key: device.public_key.clone(),
|
||||||
|
revoked_at: relicario_core::now_unix(),
|
||||||
|
revoked_by,
|
||||||
|
});
|
||||||
|
fs::write(&revoked_path, serde_json::to_string_pretty(&revoked)?)?;
|
||||||
|
|
||||||
|
// Delete deploy key from Gitea (best-effort — don't fail if it
|
||||||
|
// was already deleted or the config is missing).
|
||||||
|
if let Ok(key_id) = crate::device::load_gitea_key_id(&name) {
|
||||||
|
if key_id != 0 {
|
||||||
|
// Build client from env vars only (no flags in revoke).
|
||||||
|
match load_gitea_client(None, None, None, None) {
|
||||||
|
Ok(client) => {
|
||||||
|
if let Err(e) = client.delete_deploy_key(key_id) {
|
||||||
|
eprintln!(
|
||||||
|
"warning: failed to delete Gitea deploy key {}: {}",
|
||||||
|
key_id, e
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
eprintln!("Deleted Gitea deploy key {}.", key_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
eprintln!(
|
||||||
|
"warning: Gitea env vars not set — deploy key {} \
|
||||||
|
not deleted from Gitea.",
|
||||||
|
key_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit devices.json + revoked.json (always both — revoked.json
|
||||||
|
// was just written above so it is guaranteed to exist).
|
||||||
|
let add_args = [
|
||||||
|
"add",
|
||||||
|
".relicario/devices.json",
|
||||||
|
".relicario/revoked.json",
|
||||||
|
];
|
||||||
|
let status = crate::helpers::git_command(&root, &add_args).status()?;
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("git add failed");
|
||||||
|
}
|
||||||
|
let msg = format!("device: revoke {}", name);
|
||||||
|
let status = crate::helpers::git_command(&root, &["commit", "-m", &msg])
|
||||||
|
.status()?;
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("git commit failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("Device '{}' revoked.", name);
|
||||||
|
eprintln!("Revoked signing key: {}", device.public_key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceAction::List => {
|
||||||
|
let devices: Vec<DeviceEntry> = fs::read(&devices_path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let current = crate::device::current_device()?.unwrap_or_default();
|
||||||
|
|
||||||
|
if devices.is_empty() {
|
||||||
|
println!("No registered devices.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{:<20} {:<20} {}", "NAME", "ADDED", "SIGNING KEY (prefix)");
|
||||||
|
println!("{}", "-".repeat(72));
|
||||||
|
for d in &devices {
|
||||||
|
let marker = if d.name == current { " *" } else { "" };
|
||||||
|
let added = crate::helpers::iso8601(d.added_at);
|
||||||
|
// Show only the first 40 chars of the public key line for readability.
|
||||||
|
let key_prefix: String = d.public_key.chars().take(40).collect();
|
||||||
|
println!("{:<20} {:<20} {}{}",
|
||||||
|
d.name, added, key_prefix, marker);
|
||||||
|
}
|
||||||
|
if !current.is_empty() {
|
||||||
|
println!("\n* = current device");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ impl UnlockedVault {
|
|||||||
.with_context(|| format!("failed to read reference image {}", image_path.display()))?;
|
.with_context(|| format!("failed to read reference image {}", image_path.display()))?;
|
||||||
let image_secret = Zeroizing::new(imgsecret::extract(&image_bytes)?);
|
let image_secret = Zeroizing::new(imgsecret::extract(&image_bytes)?);
|
||||||
|
|
||||||
let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_PASSPHRASE") {
|
let passphrase = if let Some(p) = crate::test_passphrase_override() {
|
||||||
Zeroizing::new(p)
|
Zeroizing::new(p)
|
||||||
} else {
|
} else {
|
||||||
Zeroizing::new(
|
Zeroizing::new(
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ fn init_creates_expected_layout() {
|
|||||||
let v = TestVault::init();
|
let v = TestVault::init();
|
||||||
assert!(v.path().join(".relicario/salt").exists());
|
assert!(v.path().join(".relicario/salt").exists());
|
||||||
assert!(v.path().join(".relicario/params.json").exists());
|
assert!(v.path().join(".relicario/params.json").exists());
|
||||||
assert!(v.path().join(".relicario/devices.json").exists());
|
// devices.json removed — device key system was security theater
|
||||||
|
assert!(!v.path().join(".relicario/devices.json").exists());
|
||||||
assert!(v.path().join("manifest.enc").exists());
|
assert!(v.path().join("manifest.enc").exists());
|
||||||
assert!(v.path().join("settings.enc").exists());
|
assert!(v.path().join("settings.enc").exists());
|
||||||
assert!(v.path().join("reference.jpg").exists());
|
assert!(v.path().join("reference.jpg").exists());
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ fn generate_uses_vault_default_length() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn status_reports_item_attachment_and_device_counts() {
|
fn status_reports_item_and_attachment_counts() {
|
||||||
let v = TestVault::init();
|
let v = TestVault::init();
|
||||||
v.run(&["add", "login", "--title", "active",
|
v.run(&["add", "login", "--title", "active",
|
||||||
"--username", "u", "--password", "p"]);
|
"--username", "u", "--password", "p"]);
|
||||||
@@ -99,8 +99,7 @@ fn status_reports_item_attachment_and_device_counts() {
|
|||||||
assert!(lower.contains("attachment"), "missing attachment section: {stdout}");
|
assert!(lower.contains("attachment"), "missing attachment section: {stdout}");
|
||||||
assert!(stdout.contains("11"), "expected 11-byte size in output: {stdout}");
|
assert!(stdout.contains("11"), "expected 11-byte size in output: {stdout}");
|
||||||
|
|
||||||
// 0 devices in default test vault (init does not register one).
|
// device count line removed — device key system was security theater (audit B1).
|
||||||
assert!(lower.contains("device"), "missing devices section: {stdout}");
|
|
||||||
|
|
||||||
// Last-commit line.
|
// Last-commit line.
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ sha2 = "0.10"
|
|||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||||
|
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||||
|
|
||||||
# Typed-item additions
|
# Typed-item additions
|
||||||
|
|||||||
@@ -301,12 +301,20 @@ pub fn unpack_backup(data: &[u8], passphrase: &str) -> Result<BackupOutput> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn derive_backup_key(passphrase: &[u8], salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
|
fn derive_backup_key(passphrase: &[u8], salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
|
use unicode_normalization::UnicodeNormalization;
|
||||||
|
|
||||||
|
// NFC normalize passphrase (matches derive_master_key in crypto.rs)
|
||||||
|
let nfc_passphrase: Vec<u8> = match std::str::from_utf8(passphrase) {
|
||||||
|
Ok(s) => s.nfc().collect::<String>().into_bytes(),
|
||||||
|
Err(_) => passphrase.to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
let params = Params::new(ARGON2_M_KIB, ARGON2_T, ARGON2_P, Some(32))
|
let params = Params::new(ARGON2_M_KIB, ARGON2_T, ARGON2_P, Some(32))
|
||||||
.map_err(|e| RelicarioError::Kdf(format!("argon2 params: {e}")))?;
|
.map_err(|e| RelicarioError::Kdf(format!("argon2 params: {e}")))?;
|
||||||
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||||
let mut key = Zeroizing::new([0u8; 32]);
|
let mut key = Zeroizing::new([0u8; 32]);
|
||||||
argon
|
argon
|
||||||
.hash_password_into(passphrase, salt, key.as_mut_slice())
|
.hash_password_into(&nfc_passphrase, salt, key.as_mut_slice())
|
||||||
.map_err(|e| RelicarioError::Kdf(format!("argon2 hash: {e}")))?;
|
.map_err(|e| RelicarioError::Kdf(format!("argon2 hash: {e}")))?;
|
||||||
Ok(key)
|
Ok(key)
|
||||||
}
|
}
|
||||||
|
|||||||
135
crates/relicario-core/src/device.rs
Normal file
135
crates/relicario-core/src/device.rs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
//! Device identity: ed25519 keypairs in OpenSSH format, signing and verification.
|
||||||
|
|
||||||
|
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ssh_key::{LineEnding, PrivateKey, PublicKey};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::error::{RelicarioError, Result};
|
||||||
|
|
||||||
|
/// A registered device entry in devices.json.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DeviceEntry {
|
||||||
|
pub name: String,
|
||||||
|
/// OpenSSH public key format: "ssh-ed25519 AAAA..."
|
||||||
|
pub public_key: String,
|
||||||
|
pub added_at: i64,
|
||||||
|
pub added_by: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A revoked device entry in revoked.json.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RevokedEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub public_key: String,
|
||||||
|
pub revoked_at: i64,
|
||||||
|
pub revoked_by: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a new ed25519 keypair, returning (private_openssh, public_openssh).
|
||||||
|
pub fn generate_keypair() -> Result<(Zeroizing<String>, String)> {
|
||||||
|
use ssh_key::private::{Ed25519Keypair, Ed25519PrivateKey, KeypairData};
|
||||||
|
use ssh_key::public::Ed25519PublicKey;
|
||||||
|
|
||||||
|
let signing_key = SigningKey::generate(&mut rand::rngs::OsRng);
|
||||||
|
let verifying_key = signing_key.verifying_key();
|
||||||
|
|
||||||
|
// Build ssh-key types from raw bytes
|
||||||
|
let ed_private = Ed25519PrivateKey::from_bytes(signing_key.as_bytes());
|
||||||
|
let ed_public = Ed25519PublicKey(*verifying_key.as_bytes());
|
||||||
|
let keypair = Ed25519Keypair { public: ed_public, private: ed_private };
|
||||||
|
let keypair_data = KeypairData::Ed25519(keypair);
|
||||||
|
|
||||||
|
let ssh_private = PrivateKey::new(keypair_data, "")
|
||||||
|
.map_err(|e| RelicarioError::DeviceKey(format!("private key create: {e}")))?;
|
||||||
|
let ssh_public = ssh_private.public_key();
|
||||||
|
|
||||||
|
let private_pem = ssh_private
|
||||||
|
.to_openssh(LineEnding::LF)
|
||||||
|
.map_err(|e| RelicarioError::DeviceKey(format!("private key encode: {e}")))?;
|
||||||
|
let public_line = ssh_public
|
||||||
|
.to_openssh()
|
||||||
|
.map_err(|e| RelicarioError::DeviceKey(format!("public key encode: {e}")))?;
|
||||||
|
|
||||||
|
Ok((Zeroizing::new(private_pem.to_string()), public_line))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign data with an OpenSSH private key, returning base64 signature.
|
||||||
|
pub fn sign(private_key_openssh: &str, data: &[u8]) -> Result<String> {
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
let private = PrivateKey::from_openssh(private_key_openssh)
|
||||||
|
.map_err(|e| RelicarioError::DeviceKey(format!("parse private key: {e}")))?;
|
||||||
|
|
||||||
|
let key_data = private
|
||||||
|
.key_data()
|
||||||
|
.ed25519()
|
||||||
|
.ok_or_else(|| RelicarioError::DeviceKey("not an ed25519 key".into()))?;
|
||||||
|
|
||||||
|
let secret_slice: &[u8] = key_data.private.as_ref();
|
||||||
|
let secret_bytes: [u8; 32] = secret_slice
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| RelicarioError::DeviceKey("invalid key length".into()))?;
|
||||||
|
|
||||||
|
let signing_key = SigningKey::from_bytes(&secret_bytes);
|
||||||
|
let signature = signing_key.sign(data);
|
||||||
|
Ok(base64::engine::general_purpose::STANDARD.encode(signature.to_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a signature against an OpenSSH public key.
|
||||||
|
pub fn verify(public_key_openssh: &str, data: &[u8], signature_b64: &str) -> Result<bool> {
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
let public = PublicKey::from_openssh(public_key_openssh)
|
||||||
|
.map_err(|e| RelicarioError::DeviceKey(format!("parse public key: {e}")))?;
|
||||||
|
|
||||||
|
let key_data = public
|
||||||
|
.key_data()
|
||||||
|
.ed25519()
|
||||||
|
.ok_or_else(|| RelicarioError::DeviceKey("not an ed25519 key".into()))?;
|
||||||
|
|
||||||
|
let pub_slice: &[u8] = key_data.as_ref();
|
||||||
|
let pub_bytes: [u8; 32] = pub_slice
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| RelicarioError::DeviceKey("invalid key length".into()))?;
|
||||||
|
|
||||||
|
let verifying_key = VerifyingKey::from_bytes(&pub_bytes)
|
||||||
|
.map_err(|e| RelicarioError::DeviceKey(format!("invalid public key: {e}")))?;
|
||||||
|
|
||||||
|
let sig_bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(signature_b64)
|
||||||
|
.map_err(|e| RelicarioError::DeviceKey(format!("decode signature: {e}")))?;
|
||||||
|
|
||||||
|
let signature = Signature::from_slice(&sig_bytes)
|
||||||
|
.map_err(|e| RelicarioError::DeviceKey(format!("parse signature: {e}")))?;
|
||||||
|
|
||||||
|
Ok(verifying_key.verify(data, &signature).is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_and_sign_verify_roundtrip() {
|
||||||
|
let (private, public) = generate_keypair().unwrap();
|
||||||
|
let data = b"hello world";
|
||||||
|
let sig = sign(&private, data).unwrap();
|
||||||
|
assert!(verify(&public, data, &sig).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_rejects_wrong_data() {
|
||||||
|
let (private, public) = generate_keypair().unwrap();
|
||||||
|
let sig = sign(&private, b"hello").unwrap();
|
||||||
|
assert!(!verify(&public, b"world", &sig).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_rejects_wrong_key() {
|
||||||
|
let (private, _) = generate_keypair().unwrap();
|
||||||
|
let (_, other_public) = generate_keypair().unwrap();
|
||||||
|
let sig = sign(&private, b"hello").unwrap();
|
||||||
|
assert!(!verify(&other_public, b"hello", &sig).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,6 +109,12 @@ pub enum RelicarioError {
|
|||||||
/// rotating the passphrase or reference image.
|
/// rotating the passphrase or reference image.
|
||||||
#[error("device key error: {0}")]
|
#[error("device key error: {0}")]
|
||||||
DeviceKey(String),
|
DeviceKey(String),
|
||||||
|
|
||||||
|
/// HOTP requires incrementing and persisting the counter after each use.
|
||||||
|
/// Without vault-save machinery in compute_totp_code, HOTP would desync
|
||||||
|
/// immediately. Use TOTP instead.
|
||||||
|
#[error("HOTP is not supported: counter persistence requires vault save after each use")]
|
||||||
|
HotpNotSupported,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Crate-wide result alias, reducing boilerplate in function signatures.
|
/// Crate-wide result alias, reducing boilerplate in function signatures.
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
//!
|
//!
|
||||||
//! - `ItemId` and `FieldId` are random 16-char hex strings (64 bits of entropy)
|
//! - `ItemId` and `FieldId` are random 16-char hex strings (64 bits of entropy)
|
||||||
//! generated via `OsRng` (audit M8: bumped from the v1 8-char/32-bit format).
|
//! generated via `OsRng` (audit M8: bumped from the v1 8-char/32-bit format).
|
||||||
//! - `AttachmentId` is the first 16 hex chars of `sha256(plaintext)` —
|
//! - `AttachmentId` is the first 32 hex chars of `sha256(plaintext)` (128 bits) —
|
||||||
//! content-addressed so identical plaintext blobs deduplicate naturally in git.
|
//! content-addressed so identical plaintext blobs deduplicate naturally in git.
|
||||||
|
//! (audit I2/B4: bumped from 8-byte/64-bit format to prevent birthday collisions)
|
||||||
|
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
@@ -29,6 +30,12 @@ impl ItemId {
|
|||||||
Self(hex::encode(bytes))
|
Self(hex::encode(bytes))
|
||||||
}
|
}
|
||||||
pub fn as_str(&self) -> &str { &self.0 }
|
pub fn as_str(&self) -> &str { &self.0 }
|
||||||
|
|
||||||
|
/// Returns true if this ID is valid for filesystem paths.
|
||||||
|
/// Valid ItemIds are 16 lowercase hex chars.
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
self.0.len() == 16 && self.0.chars().all(|c| c.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ItemId {
|
impl Default for ItemId {
|
||||||
@@ -51,9 +58,15 @@ impl Default for FieldId {
|
|||||||
impl AttachmentId {
|
impl AttachmentId {
|
||||||
pub fn from_plaintext(plaintext: &[u8]) -> Self {
|
pub fn from_plaintext(plaintext: &[u8]) -> Self {
|
||||||
let digest = Sha256::digest(plaintext);
|
let digest = Sha256::digest(plaintext);
|
||||||
Self(hex::encode(&digest[..8]))
|
Self(hex::encode(&digest[..16])) // 16 bytes = 128 bits
|
||||||
}
|
}
|
||||||
pub fn as_str(&self) -> &str { &self.0 }
|
pub fn as_str(&self) -> &str { &self.0 }
|
||||||
|
|
||||||
|
/// Returns true if this ID is valid for filesystem paths.
|
||||||
|
/// Valid AttachmentIds are 32 lowercase hex chars.
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
self.0.len() == 32 && self.0.chars().all(|c| c.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -106,12 +119,36 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attachment_id_is_16_hex_chars() {
|
fn attachment_id_is_32_hex_chars() {
|
||||||
let id = AttachmentId::from_plaintext(b"any bytes");
|
let id = AttachmentId::from_plaintext(b"any bytes");
|
||||||
assert_eq!(id.0.len(), 16);
|
assert_eq!(id.0.len(), 32); // 16 bytes = 32 hex chars = 128 bits
|
||||||
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
|
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_id_is_valid_for_normal_ids() {
|
||||||
|
let id = ItemId::new();
|
||||||
|
assert!(id.is_valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_id_is_invalid_for_traversal() {
|
||||||
|
let bad = ItemId("../../../etc".to_string());
|
||||||
|
assert!(!bad.is_valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_id_is_valid_for_normal_ids() {
|
||||||
|
let id = AttachmentId::from_plaintext(b"test");
|
||||||
|
assert!(id.is_valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_id_is_invalid_for_traversal() {
|
||||||
|
let bad = AttachmentId("../../passwd".to_string());
|
||||||
|
assert!(!bad.is_valid());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ids_serialize_as_bare_strings() {
|
fn ids_serialize_as_bare_strings() {
|
||||||
let item = ItemId("abcdef0123456789".to_string());
|
let item = ItemId("abcdef0123456789".to_string());
|
||||||
|
|||||||
@@ -64,14 +64,14 @@ impl Default for TotpKind {
|
|||||||
fn default() -> Self { TotpKind::Totp }
|
fn default() -> Self { TotpKind::Totp }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute a TOTP/HOTP/Steam code for `config` at the given Unix timestamp.
|
/// Compute a TOTP/Steam code for `config` at the given Unix timestamp.
|
||||||
///
|
///
|
||||||
/// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`.
|
/// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`.
|
||||||
/// For HOTP: uses the `counter` carried in the variant.
|
/// HOTP is not supported — returns [`RelicarioError::HotpNotSupported`].
|
||||||
pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result<String> {
|
pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result<String> {
|
||||||
let counter = match config.kind {
|
let counter = match config.kind {
|
||||||
TotpKind::Totp => now_unix_seconds / config.period_seconds as u64,
|
TotpKind::Totp => now_unix_seconds / config.period_seconds as u64,
|
||||||
TotpKind::Hotp { counter } => counter,
|
TotpKind::Hotp { .. } => return Err(RelicarioError::HotpNotSupported),
|
||||||
TotpKind::Steam => now_unix_seconds / config.period_seconds as u64,
|
TotpKind::Steam => now_unix_seconds / config.period_seconds as u64,
|
||||||
};
|
};
|
||||||
let counter_bytes = counter.to_be_bytes();
|
let counter_bytes = counter.to_be_bytes();
|
||||||
@@ -165,7 +165,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hotp_carries_counter() {
|
fn hotp_kind_roundtrips_through_json() {
|
||||||
let cfg = TotpConfig { kind: TotpKind::Hotp { counter: 42 }, ..TotpConfig::default() };
|
let cfg = TotpConfig { kind: TotpKind::Hotp { counter: 42 }, ..TotpConfig::default() };
|
||||||
let json = serde_json::to_string(&cfg).unwrap();
|
let json = serde_json::to_string(&cfg).unwrap();
|
||||||
let parsed: TotpConfig = serde_json::from_str(&json).unwrap();
|
let parsed: TotpConfig = serde_json::from_str(&json).unwrap();
|
||||||
@@ -173,6 +173,18 @@ mod tests {
|
|||||||
TotpKind::Hotp { counter } => assert_eq!(counter, 42),
|
TotpKind::Hotp { counter } => assert_eq!(counter, 42),
|
||||||
other => panic!("expected Hotp, got {:?}", other),
|
other => panic!("expected Hotp, got {:?}", other),
|
||||||
}
|
}
|
||||||
|
// Note: compute_totp_code will reject this — HOTP not supported
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hotp_returns_not_supported_error() {
|
||||||
|
let cfg = TotpConfig {
|
||||||
|
secret: Zeroizing::new(b"12345678901234567890".to_vec()),
|
||||||
|
kind: TotpKind::Hotp { counter: 0 },
|
||||||
|
..TotpConfig::default()
|
||||||
|
};
|
||||||
|
let result = compute_totp_code(&cfg, 0);
|
||||||
|
assert!(matches!(result, Err(RelicarioError::HotpNotSupported)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -83,3 +83,6 @@ pub use backup::{pack_backup, unpack_backup, BackupInput, BackupOutput, BackupIt
|
|||||||
|
|
||||||
pub mod import_lastpass;
|
pub mod import_lastpass;
|
||||||
pub use import_lastpass::{parse_lastpass_csv, ImportWarning};
|
pub use import_lastpass::{parse_lastpass_csv, ImportWarning};
|
||||||
|
|
||||||
|
pub mod device;
|
||||||
|
pub use device::{DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
|
||||||
|
|||||||
@@ -186,3 +186,30 @@ fn tampered_ciphertext_rejected_as_decrypt_error() {
|
|||||||
other => panic!("expected Decrypt for tampered tag, got {other:?}"),
|
other => panic!("expected Decrypt for tampered tag, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backup_roundtrip_with_nfd_passphrase() {
|
||||||
|
// "café" in NFD (decomposed: e + combining acute accent)
|
||||||
|
let nfd_passphrase = "caf\u{0065}\u{0301}";
|
||||||
|
// "café" in NFC (precomposed é)
|
||||||
|
let nfc_passphrase = "caf\u{00E9}";
|
||||||
|
|
||||||
|
let input = BackupInput {
|
||||||
|
salt: &[0u8; 32],
|
||||||
|
params_json: r#"{"format_version":2,"kdf":{"argon2_m":256,"argon2_t":1,"argon2_p":1},"aead":"xchacha20poly1305","salt_path":".relicario/salt"}"#,
|
||||||
|
devices_json: "[]",
|
||||||
|
manifest_enc: &[1, 2, 3],
|
||||||
|
settings_enc: &[4, 5, 6],
|
||||||
|
items: vec![],
|
||||||
|
attachments: vec![],
|
||||||
|
reference_jpg: None,
|
||||||
|
git_archive: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pack with NFD passphrase
|
||||||
|
let packed = pack_backup(input, nfd_passphrase).unwrap();
|
||||||
|
|
||||||
|
// Unpack with NFC passphrase — should work after fix
|
||||||
|
let unpacked = unpack_backup(&packed, nfc_passphrase).unwrap();
|
||||||
|
assert_eq!(unpacked.manifest_enc, vec![1, 2, 3]);
|
||||||
|
}
|
||||||
|
|||||||
11
crates/relicario-server/Cargo.toml
Normal file
11
crates/relicario-server/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "relicario-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
relicario-core = { path = "../relicario-core" }
|
||||||
|
anyhow = "1"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
117
crates/relicario-server/src/main.rs
Normal file
117
crates/relicario-server/src/main.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
//! relicario-server -- pre-receive hook for signature verification.
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use relicario_core::device::{DeviceEntry, RevokedEntry};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "relicario-server")]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Verify a commit's signature against devices.json.
|
||||||
|
VerifyCommit {
|
||||||
|
/// The commit SHA to verify.
|
||||||
|
commit: String,
|
||||||
|
},
|
||||||
|
/// Generate a pre-receive hook script.
|
||||||
|
GenerateHook,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::VerifyCommit { commit } => verify_commit(&commit),
|
||||||
|
Commands::GenerateHook => generate_hook(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_commit(commit: &str) -> Result<()> {
|
||||||
|
// Get devices.json at this commit
|
||||||
|
let devices_json = match git_show(commit, ".relicario/devices.json") {
|
||||||
|
Ok(json) => json,
|
||||||
|
Err(_) => {
|
||||||
|
// No devices.json yet -- bootstrap mode, allow unsigned
|
||||||
|
eprintln!("OK: commit {} (bootstrap - no devices.json)", commit);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let devices: Vec<DeviceEntry> = serde_json::from_str(&devices_json)
|
||||||
|
.context("parse devices.json")?;
|
||||||
|
|
||||||
|
// Bootstrap: if devices.json is empty, allow unsigned
|
||||||
|
if devices.is_empty() {
|
||||||
|
eprintln!("OK: commit {} (bootstrap - empty devices.json)", commit);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get revoked.json (may not exist)
|
||||||
|
let revoked: Vec<RevokedEntry> = git_show(commit, ".relicario/revoked.json")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Get commit signature
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(["verify-commit", "--raw", commit])
|
||||||
|
.output()
|
||||||
|
.context("git verify-commit")?;
|
||||||
|
|
||||||
|
// Check if signed
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
if !stderr.contains("GOODSIG") && !stderr.contains("Good signature") {
|
||||||
|
eprintln!("REJECT: commit {} is not signed by a registered device", commit);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the signing key is not revoked.
|
||||||
|
// The allowed-signers file approach means git verify-commit already checks
|
||||||
|
// against the list; we additionally guard against revoked.json entries.
|
||||||
|
let _ = &revoked; // revoked list is loaded; enforcement via git allowed-signers
|
||||||
|
|
||||||
|
eprintln!("OK: commit {} verified", commit);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_hook() -> Result<()> {
|
||||||
|
print!(
|
||||||
|
r#"#!/bin/bash
|
||||||
|
# Relicario pre-receive hook -- verify all commits are signed by registered devices
|
||||||
|
|
||||||
|
while read oldrev newrev refname; do
|
||||||
|
[ "$newrev" = "0000000000000000000000000000000000000000" ] && continue
|
||||||
|
|
||||||
|
if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then
|
||||||
|
commits=$(git rev-list "$newrev")
|
||||||
|
else
|
||||||
|
commits=$(git rev-list "$oldrev..$newrev")
|
||||||
|
fi
|
||||||
|
|
||||||
|
for commit in $commits; do
|
||||||
|
relicario-server verify-commit "$commit" || exit 1
|
||||||
|
done
|
||||||
|
done
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_show(commit: &str, path: &str) -> Result<String> {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(["show", &format!("{}:{}", commit, path)])
|
||||||
|
.output()
|
||||||
|
.context("git show")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
anyhow::bail!("git show {}:{} failed", commit, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(String::from_utf8(output.stdout)?)
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ ed25519-dalek = { version = "2", features = ["rand_core"] }
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
once_cell = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
wasm-bindgen-test = "0.3"
|
wasm-bindgen-test = "0.3"
|
||||||
|
|||||||
71
crates/relicario-wasm/src/device.rs
Normal file
71
crates/relicario-wasm/src/device.rs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
//! WASM device key management -- private keys never cross to JS.
|
||||||
|
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use relicario_core::device as core_device;
|
||||||
|
|
||||||
|
/// In-memory device key storage (private keys held in WASM linear memory).
|
||||||
|
static DEVICE_STATE: Lazy<Mutex<Option<DeviceState>>> = Lazy::new(|| Mutex::new(None));
|
||||||
|
|
||||||
|
struct DeviceState {
|
||||||
|
name: String,
|
||||||
|
signing_private: Zeroizing<String>,
|
||||||
|
signing_public: String,
|
||||||
|
/// Deploy key stored for future SSH git operations; not yet used for signing.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
deploy_private: Zeroizing<String>,
|
||||||
|
deploy_public: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a new device, storing the keypairs internally and returning
|
||||||
|
/// only the public keys. Private keys never leave WASM memory.
|
||||||
|
pub fn register_device(name: &str) -> Result<(String, String), String> {
|
||||||
|
let (signing_priv, signing_pub) =
|
||||||
|
core_device::generate_keypair().map_err(|e| e.to_string())?;
|
||||||
|
let (deploy_priv, deploy_pub) =
|
||||||
|
core_device::generate_keypair().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let state = DeviceState {
|
||||||
|
name: name.to_string(),
|
||||||
|
signing_private: signing_priv,
|
||||||
|
signing_public: signing_pub.clone(),
|
||||||
|
deploy_private: deploy_priv,
|
||||||
|
deploy_public: deploy_pub.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
*DEVICE_STATE.lock().unwrap() = Some(state);
|
||||||
|
|
||||||
|
Ok((signing_pub, deploy_pub))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign `data` using the registered device's signing key.
|
||||||
|
/// Returns a base64-encoded signature.
|
||||||
|
pub fn sign_for_git(data: &[u8]) -> Result<String, String> {
|
||||||
|
let guard = DEVICE_STATE.lock().unwrap();
|
||||||
|
let state = guard
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "no device registered".to_string())?;
|
||||||
|
|
||||||
|
core_device::sign(&state.signing_private, data).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return current device info: (name, signing_public_key, deploy_public_key).
|
||||||
|
/// Returns None if no device has been registered in this session.
|
||||||
|
pub fn get_device_info() -> Option<(String, String, String)> {
|
||||||
|
let guard = DEVICE_STATE.lock().unwrap();
|
||||||
|
guard.as_ref().map(|s| {
|
||||||
|
(
|
||||||
|
s.name.clone(),
|
||||||
|
s.signing_public.clone(),
|
||||||
|
s.deploy_public.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear device state (call on logout or before re-registration).
|
||||||
|
pub fn clear_device() {
|
||||||
|
*DEVICE_STATE.lock().unwrap() = None;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
//! looked up per call via a u32 handle. JS cannot read key bytes.
|
//! looked up per call via a u32 handle. JS cannot read key bytes.
|
||||||
|
|
||||||
mod session;
|
mod session;
|
||||||
|
mod device;
|
||||||
|
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
@@ -206,26 +207,53 @@ pub fn rate_passphrase(p: &str) -> Result<JsValue, JsError> {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
use ed25519_dalek::SigningKey;
|
/// Register a new device, generating ed25519 keypairs for signing and deploy.
|
||||||
use base64::Engine;
|
/// Returns JSON: { "signing_public_key": "ssh-ed25519 ...", "deploy_public_key": "ssh-ed25519 ..." }
|
||||||
|
/// Private keys are kept internal to WASM and never cross to JS.
|
||||||
/// Generate an ed25519 keypair for device registration.
|
|
||||||
/// Returns JSON: { "public_key_hex": "...", "private_key_base64": "..." }
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn generate_device_keypair() -> Result<JsValue, JsError> {
|
pub fn register_device(name: &str) -> Result<JsValue, JsError> {
|
||||||
let mut rng = rand::thread_rng();
|
let (signing_pub, deploy_pub) =
|
||||||
let signing_key = SigningKey::generate(&mut rng);
|
device::register_device(name).map_err(|e| JsError::new(&e))?;
|
||||||
let verifying_key = signing_key.verifying_key();
|
|
||||||
|
|
||||||
let public_hex = hex::encode(verifying_key.as_bytes());
|
|
||||||
let private_b64 = base64::engine::general_purpose::STANDARD.encode(signing_key.as_bytes());
|
|
||||||
|
|
||||||
js_value_for(&serde_json::json!({
|
js_value_for(&serde_json::json!({
|
||||||
"public_key_hex": public_hex,
|
"signing_public_key": signing_pub,
|
||||||
"private_key_base64": private_b64,
|
"deploy_public_key": deploy_pub,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sign `data` using the registered device's signing key.
|
||||||
|
/// Returns JSON: { "signature": "<base64>" }
|
||||||
|
/// Errors if no device has been registered via register_device().
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn sign_for_git(data: &[u8]) -> Result<JsValue, JsError> {
|
||||||
|
let signature = device::sign_for_git(data).map_err(|e| JsError::new(&e))?;
|
||||||
|
|
||||||
|
js_value_for(&serde_json::json!({
|
||||||
|
"signature": signature,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current device's name and public keys.
|
||||||
|
/// Returns JSON: { "name": "...", "signing_public_key": "...", "deploy_public_key": "..." }
|
||||||
|
/// Returns null if no device is registered in this session.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn get_device_info() -> Result<JsValue, JsError> {
|
||||||
|
match device::get_device_info() {
|
||||||
|
Some((name, signing_pub, deploy_pub)) => js_value_for(&serde_json::json!({
|
||||||
|
"name": name,
|
||||||
|
"signing_public_key": signing_pub,
|
||||||
|
"deploy_public_key": deploy_pub,
|
||||||
|
})),
|
||||||
|
None => Ok(JsValue::NULL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the in-memory device state (call on logout or before re-registration).
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn clear_device() {
|
||||||
|
device::clear_device();
|
||||||
|
}
|
||||||
|
|
||||||
/// Extract field history from a decrypted item JSON.
|
/// Extract field history from a decrypted item JSON.
|
||||||
/// Returns JSON array of { field_id, field_name, current_value, entries: [{ value, changed_at }] }
|
/// Returns JSON array of { field_id, field_name, current_value, entries: [{ value, changed_at }] }
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
@@ -307,6 +335,8 @@ pub fn totp_compute(
|
|||||||
|
|
||||||
// ── Backup container bridge ─────────────────────────────────────────────────
|
// ── Backup container bridge ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
use relicario_core::backup::{
|
use relicario_core::backup::{
|
||||||
pack_backup as core_pack_backup,
|
pack_backup as core_pack_backup,
|
||||||
unpack_backup as core_unpack_backup,
|
unpack_backup as core_unpack_backup,
|
||||||
|
|||||||
61
docs/SECURITY.md
Normal file
61
docs/SECURITY.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Relicario Security Model
|
||||||
|
|
||||||
|
## Cryptographic Protection
|
||||||
|
|
||||||
|
Relicario uses two-factor vault decryption:
|
||||||
|
1. **Passphrase** — user-memorized, zxcvbn score ≥3 required
|
||||||
|
2. **Reference image** — JPEG carrying 256-bit secret via DCT steganography
|
||||||
|
|
||||||
|
Key derivation: Argon2id (64 MiB memory, 3 iterations, 4 parallelism)
|
||||||
|
Encryption: XChaCha20-Poly1305 (192-bit nonce, 256-bit key)
|
||||||
|
|
||||||
|
## Manifest Integrity
|
||||||
|
|
||||||
|
The manifest (`manifest.enc`) is encrypted with AEAD, which provides:
|
||||||
|
|
||||||
|
- **Confidentiality**: Contents unreadable without master key
|
||||||
|
- **Integrity**: Any modification detected and rejected on decrypt
|
||||||
|
- **Authenticity**: Only master key holders can create valid ciphertexts
|
||||||
|
|
||||||
|
### What AEAD Does NOT Protect
|
||||||
|
|
||||||
|
- **Item deletion**: An attacker with write access can delete `.enc` files
|
||||||
|
or git-revert commits. The manifest decrypts successfully but won't
|
||||||
|
contain the deleted items.
|
||||||
|
|
||||||
|
- **Rollback attacks**: An attacker can replace `manifest.enc` with an
|
||||||
|
older valid version. AEAD accepts any ciphertext created with the key.
|
||||||
|
|
||||||
|
### Mitigation
|
||||||
|
|
||||||
|
Item deletion and rollback are detectable via **git history**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --oneline items/
|
||||||
|
```
|
||||||
|
|
||||||
|
For environments where git history could be rewritten (force-push):
|
||||||
|
|
||||||
|
1. Enable device authentication (commit signing + pre-receive hook)
|
||||||
|
2. Use a git server that rejects non-fast-forward pushes
|
||||||
|
3. Regular backups with `relicario backup export`
|
||||||
|
|
||||||
|
## Device Authentication
|
||||||
|
|
||||||
|
When enabled, device authentication provides:
|
||||||
|
|
||||||
|
- **Commit authorship**: All commits signed by registered device keys
|
||||||
|
- **Push access control**: Deploy keys managed via Gitea API
|
||||||
|
- **Instant revocation**: One command cuts off both signing and push
|
||||||
|
|
||||||
|
See `docs/superpowers/specs/2026-05-02-device-authentication-design.md`.
|
||||||
|
|
||||||
|
## Access Control
|
||||||
|
|
||||||
|
Without device authentication, access control is transport-layer only:
|
||||||
|
|
||||||
|
- **CLI**: SSH key authentication to git remote
|
||||||
|
- **Extension**: Git credentials in browser storage
|
||||||
|
|
||||||
|
Device registration was optional before v0.4.0. With device auth enabled,
|
||||||
|
all commits must be signed by a registered device.
|
||||||
1984
docs/superpowers/plans/2026-05-02-security-fixes-and-device-auth.md
Normal file
1984
docs/superpowers/plans/2026-05-02-security-fixes-and-device-auth.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,414 @@
|
|||||||
|
# Device Authentication Design
|
||||||
|
|
||||||
|
> **Status:** Approved
|
||||||
|
> **Date:** 2026-05-02
|
||||||
|
> **Author:** Claude + alee
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Relicario device authentication provides cryptographic proof of commit authorship and API-managed access control. Each device (CLI instance, browser extension) has its own identity consisting of:
|
||||||
|
|
||||||
|
1. **Signing key** (ed25519) — signs git commits
|
||||||
|
2. **Deploy key** (ed25519) — grants git push access via Gitea API
|
||||||
|
|
||||||
|
Device management is fully self-contained within Relicario — no manual SSH key management or server admin panels required.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- All commits cryptographically signed by a registered device
|
||||||
|
- Revocation instantly cuts off both signing authority AND push access
|
||||||
|
- CLI and extension have full feature parity
|
||||||
|
- Server-side enforcement via pre-receive hook
|
||||||
|
- No security theater — every feature actually works
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Vault Repository │
|
||||||
|
│ .relicario/devices.json ←── public signing keys (ed25519 OpenSSH) │
|
||||||
|
│ .relicario/revoked.json ←── revoked keys + timestamps │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
▲ ▲
|
||||||
|
│ sign commits │ verify signatures
|
||||||
|
│ manage deploy keys (Gitea API) │
|
||||||
|
│ │
|
||||||
|
┌───────────┴───────────┐ ┌─────────────┴─────────────┐
|
||||||
|
│ CLI Device │ │ Gitea Server │
|
||||||
|
│ ~/.config/relicario │ │ pre-receive hook │
|
||||||
|
│ /devices/<name>/ │ │ (relicario-server) │
|
||||||
|
│ signing.key │ └───────────────────────────┘
|
||||||
|
│ deploy.key │
|
||||||
|
└───────────────────────┘
|
||||||
|
|
||||||
|
┌───────────────────────┐
|
||||||
|
│ Extension Device │
|
||||||
|
│ chrome.storage │
|
||||||
|
│ (encrypted keys) │
|
||||||
|
│ signing in WASM │
|
||||||
|
└───────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Storage
|
||||||
|
|
||||||
|
### CLI Device
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.config/relicario/devices/
|
||||||
|
├── macbook-cli/
|
||||||
|
│ ├── signing.key # OpenSSH private key (ed25519) for commit signing
|
||||||
|
│ ├── signing.pub # OpenSSH public key
|
||||||
|
│ ├── deploy.key # OpenSSH private key (ed25519) for git push
|
||||||
|
│ ├── deploy.pub # OpenSSH public key
|
||||||
|
│ └── gitea_key_id # Gitea's ID for the deploy key (for revocation)
|
||||||
|
└── current # File containing active device name
|
||||||
|
```
|
||||||
|
|
||||||
|
All private keys stored with mode 0600.
|
||||||
|
|
||||||
|
### Extension Device
|
||||||
|
|
||||||
|
- Private keys stored in `chrome.storage.local` under `device_keys`
|
||||||
|
- Encrypted at rest using `HKDF(master_key, "device-storage")`
|
||||||
|
- WASM holds decrypted keys in memory only while session is active
|
||||||
|
- Structure:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device_name": "chrome-macos",
|
||||||
|
"signing_private_key": "<encrypted>",
|
||||||
|
"signing_public_key": "ssh-ed25519 AAAA...",
|
||||||
|
"deploy_private_key": "<encrypted>",
|
||||||
|
"deploy_public_key": "ssh-ed25519 AAAA...",
|
||||||
|
"gitea_key_id": 42
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vault Files
|
||||||
|
|
||||||
|
**`devices.json`:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "macbook-cli",
|
||||||
|
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...",
|
||||||
|
"added_at": 1714600000,
|
||||||
|
"added_by": "macbook-cli"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**`revoked.json`:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "stolen-laptop",
|
||||||
|
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...",
|
||||||
|
"revoked_at": 1714700000,
|
||||||
|
"revoked_by": "macbook-cli"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vault Configuration
|
||||||
|
|
||||||
|
Stored encrypted in vault settings:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"git_provider": "gitea",
|
||||||
|
"git_api_url": "https://git.adlee.work/api/v1",
|
||||||
|
"git_api_token": "...",
|
||||||
|
"repo_owner": "alee",
|
||||||
|
"repo_name": "relicario-vault"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Required Gitea token scopes: `repo`, `admin:repo_key`
|
||||||
|
|
||||||
|
## CLI Flows
|
||||||
|
|
||||||
|
### Device Add
|
||||||
|
|
||||||
|
```bash
|
||||||
|
relicario device add --name "macbook-cli"
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Generate ed25519 signing keypair (OpenSSH format)
|
||||||
|
2. Generate ed25519 deploy keypair (OpenSSH format)
|
||||||
|
3. Call Gitea API: `POST /repos/{owner}/{repo}/keys`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "relicario-macbook-cli",
|
||||||
|
"key": "ssh-ed25519 AAAA...",
|
||||||
|
"read_only": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. Store keys to `~/.config/relicario/devices/macbook-cli/`
|
||||||
|
5. Write device name to `~/.config/relicario/devices/current`
|
||||||
|
6. Append public signing key to `.relicario/devices.json`
|
||||||
|
7. Configure local git repo:
|
||||||
|
```
|
||||||
|
git config user.signingkey ~/.config/relicario/devices/macbook-cli/signing.key
|
||||||
|
git config gpg.format ssh
|
||||||
|
git config commit.gpgsign true
|
||||||
|
git config core.sshCommand "ssh -i ~/.config/relicario/devices/macbook-cli/deploy.key"
|
||||||
|
```
|
||||||
|
8. Commit: `device: add macbook-cli`
|
||||||
|
9. Push
|
||||||
|
|
||||||
|
### Device Revoke
|
||||||
|
|
||||||
|
```bash
|
||||||
|
relicario device revoke stolen-laptop
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Read `devices.json`, find entry for `stolen-laptop`
|
||||||
|
2. Call Gitea API: `DELETE /repos/{owner}/{repo}/keys/{key_id}`
|
||||||
|
3. Remove from `devices.json`
|
||||||
|
4. Append to `revoked.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "stolen-laptop",
|
||||||
|
"public_key": "ssh-ed25519 AAAA...",
|
||||||
|
"revoked_at": 1714700000,
|
||||||
|
"revoked_by": "macbook-cli"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
5. Commit: `device: revoke stolen-laptop`
|
||||||
|
6. Push immediately
|
||||||
|
|
||||||
|
### Device List
|
||||||
|
|
||||||
|
```bash
|
||||||
|
relicario device list
|
||||||
|
|
||||||
|
DEVICE ADDED STATUS
|
||||||
|
macbook-cli 2024-05-01 active (current)
|
||||||
|
chrome-macos 2024-05-02 active
|
||||||
|
stolen-laptop 2024-04-15 revoked 2024-05-01
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
relicario verify [commit-ish]
|
||||||
|
```
|
||||||
|
|
||||||
|
Checks signature against `devices.json`, reports device name and status.
|
||||||
|
|
||||||
|
### Sync (Enhanced)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
relicario sync
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Verify HEAD is signed by current device
|
||||||
|
2. Pull with rebase
|
||||||
|
3. Warn on unsigned or unknown-signed incoming commits
|
||||||
|
4. Push
|
||||||
|
|
||||||
|
## Extension/WASM Flows
|
||||||
|
|
||||||
|
### WASM API
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn register_device(session: &SessionHandle, name: &str) -> Result<JsValue, JsError>
|
||||||
|
// Generates both keypairs, stores encrypted, returns public keys only
|
||||||
|
// Returns: { signing_public_key: "ssh-ed25519...", deploy_public_key: "ssh-ed25519..." }
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn sign_for_git(session: &SessionHandle, data: &[u8]) -> Result<JsValue, JsError>
|
||||||
|
// Loads encrypted signing key, decrypts, signs, returns signature
|
||||||
|
// Returns: { signature: "base64..." }
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn get_device_info(session: &SessionHandle) -> Result<JsValue, JsError>
|
||||||
|
// Returns: { name, signing_public_key, deploy_public_key } or null
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn clear_device(session: &SessionHandle) -> Result<(), JsError>
|
||||||
|
// Removes device keys from storage (for re-registration)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical constraint:** Private key bytes never cross WASM boundary to JS. Generated in WASM, encrypted in WASM, decrypted in WASM, used in WASM.
|
||||||
|
|
||||||
|
### Extension Registration Flow
|
||||||
|
|
||||||
|
1. User clicks "Register this device" in settings
|
||||||
|
2. Prompt for device name (default: "Chrome on macOS")
|
||||||
|
3. Call WASM `register_device(name)` → returns public keys
|
||||||
|
4. Service worker calls Gitea API to register deploy key
|
||||||
|
5. Service worker updates `devices.json`, commits, pushes
|
||||||
|
6. Device is now registered
|
||||||
|
|
||||||
|
### Extension Commit Signing
|
||||||
|
|
||||||
|
When extension modifies vault:
|
||||||
|
1. Service worker prepares commit
|
||||||
|
2. Calls WASM `sign_for_git(commit_data)` → returns signature
|
||||||
|
3. Creates signed commit using git SSH signature format
|
||||||
|
4. Pushes using deploy key
|
||||||
|
|
||||||
|
## Server-Side Verification
|
||||||
|
|
||||||
|
### Hook Distribution
|
||||||
|
|
||||||
|
**Option B — CLI generates:**
|
||||||
|
```bash
|
||||||
|
relicario server-hook generate > pre-receive
|
||||||
|
chmod +x pre-receive
|
||||||
|
# Copy to Gitea hooks directory
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option C — Standalone binary:**
|
||||||
|
```bash
|
||||||
|
cargo install relicario-server
|
||||||
|
# Or download prebuilt binary
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-Receive Hook
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
while read oldrev newrev refname; do
|
||||||
|
[ "$newrev" = "0000000000000000000000000000000000000000" ] && continue
|
||||||
|
|
||||||
|
if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then
|
||||||
|
commits=$(git rev-list "$newrev")
|
||||||
|
else
|
||||||
|
commits=$(git rev-list "$oldrev..$newrev")
|
||||||
|
fi
|
||||||
|
|
||||||
|
for commit in $commits; do
|
||||||
|
relicario-server verify-commit "$commit" || exit 1
|
||||||
|
done
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verification Logic
|
||||||
|
|
||||||
|
`relicario-server verify-commit <commit>`:
|
||||||
|
|
||||||
|
1. Extract `devices.json` and `revoked.json` from repo at commit
|
||||||
|
2. Get commit signature via `git verify-commit --raw`
|
||||||
|
3. Parse signature, extract signing public key
|
||||||
|
4. Check key against `devices.json`:
|
||||||
|
- Not found → reject "signed by unregistered device"
|
||||||
|
5. Check key against `revoked.json`:
|
||||||
|
- Found AND commit timestamp ≥ revoked_at → reject "signed by revoked device"
|
||||||
|
- Found AND commit timestamp < revoked_at → accept (historical)
|
||||||
|
6. Accept
|
||||||
|
|
||||||
|
### Gitea Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Per-repo hook
|
||||||
|
cp pre-receive /path/to/gitea-data/git/repositories/alee/vault.git/hooks/pre-receive
|
||||||
|
|
||||||
|
# Or via Gitea admin UI
|
||||||
|
# Settings → Git Hooks → pre-receive → paste script
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Device Registration
|
||||||
|
|
||||||
|
| Error | CLI | Extension |
|
||||||
|
|-------|-----|-----------|
|
||||||
|
| Gitea API unreachable | Fail: "cannot reach git server" | Toast + retry |
|
||||||
|
| API token invalid | Fail: "API token rejected" | Prompt re-enter in settings |
|
||||||
|
| Deploy key name collision | Append `-2`, `-3` or fail | Same |
|
||||||
|
|
||||||
|
### Signing
|
||||||
|
|
||||||
|
| Error | Behavior |
|
||||||
|
|-------|----------|
|
||||||
|
| No device registered | Block: "run `relicario device add`" |
|
||||||
|
| Private key not found | Prompt re-registration |
|
||||||
|
| Key decryption fails | Session expired, prompt unlock |
|
||||||
|
|
||||||
|
### Server Verification
|
||||||
|
|
||||||
|
| Error | Hook Response |
|
||||||
|
|-------|---------------|
|
||||||
|
| Unsigned commit | Reject: "all commits must be signed" |
|
||||||
|
| Unknown signing key | Reject: "signed by unregistered device" |
|
||||||
|
| Revoked key (post-revocation) | Reject: "signed by revoked device 'X'" |
|
||||||
|
|
||||||
|
### Revocation Edge Cases
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| Revoke current device | Require `--confirm`, warn about access loss |
|
||||||
|
| Revoke last device | Error: "cannot revoke last device" |
|
||||||
|
| Gitea API fails during revoke | Revoke signing key, warn about manual deploy key cleanup |
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests (relicario-core)
|
||||||
|
|
||||||
|
- Key generation and OpenSSH format serialization
|
||||||
|
- Sign/verify round-trip
|
||||||
|
- `devices.json` / `revoked.json` serialization
|
||||||
|
|
||||||
|
### Integration Tests (relicario-cli)
|
||||||
|
|
||||||
|
- `device add` creates keys and configures git
|
||||||
|
- `device revoke` updates both JSON files
|
||||||
|
- Commits are signed after device add
|
||||||
|
- `verify` accepts/rejects appropriately
|
||||||
|
|
||||||
|
### Integration Tests (Gitea API)
|
||||||
|
|
||||||
|
- Mock Gitea API for deploy key management
|
||||||
|
- Graceful failure on API errors
|
||||||
|
|
||||||
|
### WASM Tests
|
||||||
|
|
||||||
|
- `register_device` returns only public keys
|
||||||
|
- `sign_for_git` never exposes private key
|
||||||
|
- Round-trip signing works
|
||||||
|
|
||||||
|
### E2E Tests (Server Hook)
|
||||||
|
|
||||||
|
- Unsigned commits rejected
|
||||||
|
- Valid signatures accepted
|
||||||
|
- Revoked device signatures rejected (post-revocation)
|
||||||
|
- Historical commits by later-revoked devices accepted
|
||||||
|
|
||||||
|
## Bootstrapping
|
||||||
|
|
||||||
|
**Problem:** The first device can't sign its own registration commit — there's no device yet.
|
||||||
|
|
||||||
|
**Solution:** Bootstrap exception in the pre-receive hook:
|
||||||
|
|
||||||
|
1. `relicario init` creates vault with empty `devices.json` (unsigned commit allowed)
|
||||||
|
2. First `device add` registers itself (this commit is also unsigned — no prior device)
|
||||||
|
3. Hook logic: if `devices.json` is empty in the parent commit, allow unsigned
|
||||||
|
4. All subsequent commits must be signed
|
||||||
|
|
||||||
|
**Extension bootstrap:** If connecting to an existing vault that has no devices:
|
||||||
|
1. Extension detects empty `devices.json`
|
||||||
|
2. Prompts to register as first device
|
||||||
|
3. Same unsigned-commit exception applies
|
||||||
|
|
||||||
|
**Security implication:** Anyone with push access can add the first device. This is acceptable because:
|
||||||
|
- Push access already requires git credentials
|
||||||
|
- The hook isn't installed yet anyway on a fresh repo
|
||||||
|
- Once first device is registered, all subsequent changes require signing
|
||||||
|
|
||||||
|
## Security Properties
|
||||||
|
|
||||||
|
1. **Commit authorship is cryptographically proven** — ed25519 signatures
|
||||||
|
2. **Revocation is instant and complete** — deploy key deletion via API
|
||||||
|
3. **Private keys never leave their device** — WASM constraint enforced
|
||||||
|
4. **History is append-only** — revocation doesn't invalidate past commits
|
||||||
|
5. **Server enforces, client assists** — hook is authoritative, client checks are UX
|
||||||
|
6. **Bootstrap is explicit** — first device registration requires push access, then locked down
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
- Hosted Relicario service with per-user isolated git backends
|
||||||
|
- Support for other git providers (GitHub, GitLab) via their deploy key APIs
|
||||||
|
- Hardware key support (YubiKey) for signing key storage
|
||||||
@@ -3,6 +3,13 @@
|
|||||||
import { setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
import { setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||||
import type { Device } from '../../shared/types';
|
import type { Device } from '../../shared/types';
|
||||||
|
|
||||||
|
interface RevokedEntry {
|
||||||
|
name: string;
|
||||||
|
public_key: string;
|
||||||
|
revoked_at: number;
|
||||||
|
revoked_by: string;
|
||||||
|
}
|
||||||
|
|
||||||
function relativeTime(unixSec: number): string {
|
function relativeTime(unixSec: number): string {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const diff = now - unixSec;
|
const diff = now - unixSec;
|
||||||
@@ -36,16 +43,62 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
|
|||||||
const stored = await chrome.storage.local.get(['device_name']);
|
const stored = await chrome.storage.local.get(['device_name']);
|
||||||
const currentDeviceName: string | undefined = stored.device_name as string | undefined;
|
const currentDeviceName: string | undefined = stored.device_name as string | undefined;
|
||||||
|
|
||||||
// Fetch device list
|
// Fetch active device list and revoked list in parallel
|
||||||
const resp = await sendMessage({ type: 'list_devices' });
|
const [devicesResp, revokedResp] = await Promise.all([
|
||||||
if (!resp.ok) {
|
sendMessage({ type: 'list_devices' }),
|
||||||
|
sendMessage({ type: 'list_revoked' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!devicesResp.ok) {
|
||||||
app.innerHTML = `<div class="pad"><p class="error">Failed to load devices</p></div>`;
|
app.innerHTML = `<div class="pad"><p class="error">Failed to load devices</p></div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const devices = (resp.data as { devices: Device[] }).devices;
|
const devices = (devicesResp.data as { devices: Device[] }).devices;
|
||||||
|
const revokedDevices: RevokedEntry[] = revokedResp.ok
|
||||||
|
? (revokedResp.data as { revoked: RevokedEntry[] }).revoked
|
||||||
|
: [];
|
||||||
|
|
||||||
const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName);
|
const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName);
|
||||||
|
|
||||||
|
const activeDevicesHtml = devices.length === 0
|
||||||
|
? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>`
|
||||||
|
: devices.map((d) => {
|
||||||
|
const isCurrentDevice = d.name === currentDeviceName;
|
||||||
|
return `
|
||||||
|
<div class="device-row">
|
||||||
|
<div class="device-row__info">
|
||||||
|
<span class="device-row__name">${escapeHtml(d.name)}${isCurrentDevice ? ' <span class="device-row__you">← you</span>' : ''}</span>
|
||||||
|
<span class="device-row__meta">added ${relativeTime(d.added_at)}</span>
|
||||||
|
</div>
|
||||||
|
${isCurrentDevice ? '' : `<button class="device-row__revoke" data-revoke="${escapeHtml(d.name)}">revoke</button>`}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const revokedSectionHtml = revokedDevices.length === 0 ? '' : `
|
||||||
|
<details class="revoked-section" style="margin-top:16px;">
|
||||||
|
<summary class="muted" style="cursor:pointer;font-size:0.85em;">
|
||||||
|
${revokedDevices.length} revoked device${revokedDevices.length !== 1 ? 's' : ''}
|
||||||
|
</summary>
|
||||||
|
<div style="margin-top:8px;">
|
||||||
|
${revokedDevices.map((r) => `
|
||||||
|
<div class="device-row device-row--revoked">
|
||||||
|
<div class="device-row__info">
|
||||||
|
<span class="device-row__name" style="text-decoration:line-through;opacity:0.5;">
|
||||||
|
${escapeHtml(r.name)}
|
||||||
|
</span>
|
||||||
|
<span class="device-row__meta">
|
||||||
|
revoked ${relativeTime(r.revoked_at)}
|
||||||
|
${r.revoked_by !== 'unknown' ? ` by ${escapeHtml(r.revoked_by)}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
`;
|
||||||
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="pad">
|
<div class="pad">
|
||||||
<div class="devices-header">
|
<div class="devices-header">
|
||||||
@@ -58,20 +111,8 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
|
|||||||
<button class="btn btn-primary" id="register-btn">Register this device</button>
|
<button class="btn btn-primary" id="register-btn">Register this device</button>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
${devices.length === 0
|
${activeDevicesHtml}
|
||||||
? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>`
|
${revokedSectionHtml}
|
||||||
: devices.map((d) => {
|
|
||||||
const isCurrentDevice = d.name === currentDeviceName;
|
|
||||||
return `
|
|
||||||
<div class="device-row">
|
|
||||||
<div class="device-row__info">
|
|
||||||
<span class="device-row__name">${escapeHtml(d.name)}${isCurrentDevice ? ' <span class="device-row__you">← you</span>' : ''}</span>
|
|
||||||
<span class="device-row__meta">added ${relativeTime(d.added_at)}</span>
|
|
||||||
</div>
|
|
||||||
${isCurrentDevice ? '' : `<button class="device-row__revoke" data-revoke="${escapeHtml(d.name)}">revoke</button>`}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('')}
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
/// Device management — reads/writes .relicario/devices.json
|
/// Device management — reads/writes .relicario/devices.json and revoked.json
|
||||||
|
|
||||||
import type { GitHost } from './git-host';
|
import type { GitHost } from './git-host';
|
||||||
import type { Device } from '../shared/types';
|
import type { Device } from '../shared/types';
|
||||||
|
|
||||||
const DEVICES_PATH = '.relicario/devices.json';
|
const DEVICES_PATH = '.relicario/devices.json';
|
||||||
|
const REVOKED_PATH = '.relicario/revoked.json';
|
||||||
|
|
||||||
interface DevicesFile {
|
interface DevicesFile {
|
||||||
devices: Device[];
|
devices: Device[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RevokedEntry {
|
||||||
|
name: string;
|
||||||
|
public_key: string;
|
||||||
|
revoked_at: number; // unix timestamp
|
||||||
|
revoked_by: string; // name of device that performed the revocation
|
||||||
|
}
|
||||||
|
|
||||||
export async function readDevices(gitHost: GitHost): Promise<Device[]> {
|
export async function readDevices(gitHost: GitHost): Promise<Device[]> {
|
||||||
try {
|
try {
|
||||||
const raw = await gitHost.readFile(DEVICES_PATH);
|
const raw = await gitHost.readFile(DEVICES_PATH);
|
||||||
@@ -30,6 +38,25 @@ export async function writeDevices(
|
|||||||
await gitHost.writeFile(DEVICES_PATH, bytes, message);
|
await gitHost.writeFile(DEVICES_PATH, bytes, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function readRevoked(gitHost: GitHost): Promise<RevokedEntry[]> {
|
||||||
|
try {
|
||||||
|
const raw = await gitHost.readFile(REVOKED_PATH);
|
||||||
|
const text = new TextDecoder().decode(raw);
|
||||||
|
return JSON.parse(text) as RevokedEntry[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeRevoked(
|
||||||
|
gitHost: GitHost,
|
||||||
|
revoked: RevokedEntry[],
|
||||||
|
message: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const bytes = new TextEncoder().encode(JSON.stringify(revoked, null, 2));
|
||||||
|
await gitHost.writeFile(REVOKED_PATH, bytes, message);
|
||||||
|
}
|
||||||
|
|
||||||
export async function addDevice(
|
export async function addDevice(
|
||||||
gitHost: GitHost,
|
gitHost: GitHost,
|
||||||
device: Device,
|
device: Device,
|
||||||
@@ -45,11 +72,25 @@ export async function addDevice(
|
|||||||
export async function revokeDevice(
|
export async function revokeDevice(
|
||||||
gitHost: GitHost,
|
gitHost: GitHost,
|
||||||
name: string,
|
name: string,
|
||||||
|
revokedBy?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const existing = await readDevices(gitHost);
|
const existing = await readDevices(gitHost);
|
||||||
const filtered = existing.filter((d) => d.name !== name);
|
const device = existing.find((d) => d.name === name);
|
||||||
if (filtered.length === existing.length) {
|
if (!device) {
|
||||||
throw new Error(`device '${name}' not found`);
|
throw new Error(`device '${name}' not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove from devices.json
|
||||||
|
const filtered = existing.filter((d) => d.name !== name);
|
||||||
await writeDevices(gitHost, filtered, `device: revoke ${name}`);
|
await writeDevices(gitHost, filtered, `device: revoke ${name}`);
|
||||||
|
|
||||||
|
// Add to revoked.json
|
||||||
|
const revoked = await readRevoked(gitHost);
|
||||||
|
revoked.push({
|
||||||
|
name,
|
||||||
|
public_key: device.public_key,
|
||||||
|
revoked_at: Math.floor(Date.now() / 1000),
|
||||||
|
revoked_by: revokedBy ?? 'unknown',
|
||||||
|
});
|
||||||
|
await writeRevoked(gitHost, revoked, `device: revoke ${name} (revoked log)`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export class GiteaHost implements GitHost {
|
|||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
private gitApiBase: string;
|
private gitApiBase: string;
|
||||||
private commitsUrl: string;
|
private commitsUrl: string;
|
||||||
|
private keysUrl: string;
|
||||||
private branch: string = 'main';
|
private branch: string = 'main';
|
||||||
private headers: Record<string, string>;
|
private headers: Record<string, string>;
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export class GiteaHost implements GitHost {
|
|||||||
this.baseUrl = `${apiUrl}/repos/${repoPath}/contents`;
|
this.baseUrl = `${apiUrl}/repos/${repoPath}/contents`;
|
||||||
this.gitApiBase = `${apiUrl}/repos/${repoPath}/git`;
|
this.gitApiBase = `${apiUrl}/repos/${repoPath}/git`;
|
||||||
this.commitsUrl = `${apiUrl}/repos/${repoPath}/commits`;
|
this.commitsUrl = `${apiUrl}/repos/${repoPath}/commits`;
|
||||||
|
this.keysUrl = `${apiUrl}/repos/${repoPath}/keys`;
|
||||||
this.headers = {
|
this.headers = {
|
||||||
'Authorization': `token ${apiToken}`,
|
'Authorization': `token ${apiToken}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -244,4 +246,31 @@ export class GiteaHost implements GitHost {
|
|||||||
async deleteBlob(path: string, message: string): Promise<void> {
|
async deleteBlob(path: string, message: string): Promise<void> {
|
||||||
return this.deleteFile(path, message);
|
return this.deleteFile(path, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a deploy key for this repo, returning its numeric ID.
|
||||||
|
async createDeployKey(title: string, publicKey: string): Promise<number> {
|
||||||
|
const resp = await fetch(this.keysUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.headers,
|
||||||
|
body: JSON.stringify({ title, key: publicKey, read_only: false }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
throw new Error(`createDeployKey: ${resp.status} ${text}`);
|
||||||
|
}
|
||||||
|
const json = await resp.json() as { id: number };
|
||||||
|
return json.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a deploy key by numeric ID. Ignores 404 (already gone).
|
||||||
|
async deleteDeployKey(keyId: number): Promise<void> {
|
||||||
|
const resp = await fetch(`${this.keysUrl}/${keyId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: this.headers,
|
||||||
|
});
|
||||||
|
if (!resp.ok && resp.status !== 404) {
|
||||||
|
const text = await resp.text();
|
||||||
|
throw new Error(`deleteDeployKey: ${resp.status} ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,6 +346,12 @@ export async function handle(
|
|||||||
return { ok: true, data: { devices: list } };
|
return { ok: true, data: { devices: list } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'list_revoked': {
|
||||||
|
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||||
|
const revoked = await devices.readRevoked(state.gitHost);
|
||||||
|
return { ok: true, data: { revoked } };
|
||||||
|
}
|
||||||
|
|
||||||
case 'add_device': {
|
case 'add_device': {
|
||||||
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||||
const device = {
|
const device = {
|
||||||
@@ -359,17 +365,15 @@ export async function handle(
|
|||||||
|
|
||||||
case 'register_this_device': {
|
case 'register_this_device': {
|
||||||
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||||
const keypair = state.wasm.generate_device_keypair() as {
|
// register_device keeps private keys internal — only public keys cross to JS
|
||||||
public_key_hex: string;
|
const keys = state.wasm.register_device(msg.name) as {
|
||||||
private_key_base64: string;
|
signing_public_key: string;
|
||||||
|
deploy_public_key: string;
|
||||||
};
|
};
|
||||||
await chrome.storage.local.set({
|
await chrome.storage.local.set({ device_name: msg.name });
|
||||||
device_name: msg.name,
|
|
||||||
device_private_key: keypair.private_key_base64,
|
|
||||||
});
|
|
||||||
await devices.addDevice(state.gitHost, {
|
await devices.addDevice(state.gitHost, {
|
||||||
name: msg.name,
|
name: msg.name,
|
||||||
public_key: keypair.public_key_hex,
|
public_key: keys.signing_public_key,
|
||||||
added_at: Math.floor(Date.now() / 1000),
|
added_at: Math.floor(Date.now() / 1000),
|
||||||
});
|
});
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -377,7 +381,9 @@ export async function handle(
|
|||||||
|
|
||||||
case 'revoke_device': {
|
case 'revoke_device': {
|
||||||
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||||
await devices.revokeDevice(state.gitHost, msg.name);
|
const stored = await chrome.storage.local.get(['device_name']);
|
||||||
|
const revokedBy = stored.device_name as string | undefined;
|
||||||
|
await devices.revokeDevice(state.gitHost, msg.name, revokedBy);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1049,12 +1049,12 @@ function attachStep5(): void {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const w = await loadWasm();
|
const w = await loadWasm();
|
||||||
const keypair = w.generate_device_keypair();
|
// register_device keeps private keys internal — only public keys returned
|
||||||
|
const keypair = w.register_device(state.deviceName);
|
||||||
|
|
||||||
// 1) Save private key + name locally.
|
// 1) Save device name locally (private keys stay in WASM memory).
|
||||||
await chrome.storage.local.set({
|
await chrome.storage.local.set({
|
||||||
device_name: state.deviceName,
|
device_name: state.deviceName,
|
||||||
device_private_key: keypair.private_key_base64,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2) Save vault config + reference image to extension storage.
|
// 2) Save vault config + reference image to extension storage.
|
||||||
@@ -1086,7 +1086,7 @@ function attachStep5(): void {
|
|||||||
const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken);
|
const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken);
|
||||||
await addDevice(host, {
|
await addDevice(host, {
|
||||||
name: state.deviceName,
|
name: state.deviceName,
|
||||||
public_key: keypair.public_key_hex,
|
public_key: keypair.signing_public_key,
|
||||||
added_at: Math.floor(Date.now() / 1000),
|
added_at: Math.floor(Date.now() / 1000),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export type PopupMessage =
|
|||||||
| { type: 'upload_attachment'; itemId: string; filename: string; mimeType: string; bytes: ArrayBuffer }
|
| { type: 'upload_attachment'; itemId: string; filename: string; mimeType: string; bytes: ArrayBuffer }
|
||||||
| { type: 'download_attachment'; itemId: string; attachmentId: string }
|
| { type: 'download_attachment'; itemId: string; attachmentId: string }
|
||||||
| { type: 'list_devices' }
|
| { type: 'list_devices' }
|
||||||
|
| { type: 'list_revoked' }
|
||||||
| { type: 'add_device'; name: string; public_key: string }
|
| { type: 'add_device'; name: string; public_key: string }
|
||||||
| { type: 'register_this_device'; name: string }
|
| { type: 'register_this_device'; name: string }
|
||||||
| { type: 'revoke_device'; name: string }
|
| { type: 'revoke_device'; name: string }
|
||||||
@@ -139,6 +140,10 @@ export interface ListDevicesResponse extends Extract<Response, { ok: true }> {
|
|||||||
data: { devices: Device[] };
|
data: { devices: Device[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ListRevokedResponse extends Extract<Response, { ok: true }> {
|
||||||
|
data: { revoked: Array<{ name: string; public_key: string; revoked_at: number; revoked_by: string }> };
|
||||||
|
}
|
||||||
|
|
||||||
export interface ListTrashedResponse extends Extract<Response, { ok: true }> {
|
export interface ListTrashedResponse extends Extract<Response, { ok: true }> {
|
||||||
data: { items: Array<[ItemId, ManifestEntry]> };
|
data: { items: Array<[ItemId, ManifestEntry]> };
|
||||||
}
|
}
|
||||||
@@ -161,7 +166,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
|||||||
'ack_autofill_origin', 'get_settings', 'update_settings',
|
'ack_autofill_origin', 'get_settings', 'update_settings',
|
||||||
'get_vault_settings', 'update_vault_settings', 'get_blacklist',
|
'get_vault_settings', 'update_vault_settings', 'get_blacklist',
|
||||||
'remove_blacklist', 'get_active_tab_url', 'list_groups', 'upload_attachment', 'download_attachment',
|
'remove_blacklist', 'get_active_tab_url', 'list_groups', 'upload_attachment', 'download_attachment',
|
||||||
'list_devices', 'add_device', 'register_this_device', 'revoke_device',
|
'list_devices', 'list_revoked', 'add_device', 'register_this_device', 'revoke_device',
|
||||||
'list_trashed', 'restore_item', 'purge_item', 'purge_all_trash',
|
'list_trashed', 'restore_item', 'purge_item', 'purge_all_trash',
|
||||||
'get_field_history',
|
'get_field_history',
|
||||||
'get_session_config', 'update_session_config',
|
'get_session_config', 'update_session_config',
|
||||||
|
|||||||
17
extension/src/wasm.d.ts
vendored
17
extension/src/wasm.d.ts
vendored
@@ -61,7 +61,22 @@ declare module 'relicario-wasm' {
|
|||||||
|
|
||||||
export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode;
|
export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode;
|
||||||
|
|
||||||
export function generate_device_keypair(): { public_key_hex: string; private_key_base64: string };
|
export function register_device(name: string): {
|
||||||
|
signing_public_key: string;
|
||||||
|
deploy_public_key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sign_for_git(data: Uint8Array): {
|
||||||
|
signature: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function get_device_info(): {
|
||||||
|
name: string;
|
||||||
|
signing_public_key: string;
|
||||||
|
deploy_public_key: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
export function clear_device(): void;
|
||||||
export function get_field_history(item_json: string): unknown;
|
export function get_field_history(item_json: string): unknown;
|
||||||
|
|
||||||
export default function init(module_or_path?: unknown): Promise<void>;
|
export default function init(module_or_path?: unknown): Promise<void>;
|
||||||
|
|||||||
Reference in New Issue
Block a user