Compare commits
15 Commits
4d02a50cc8
...
feature/v0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e9d834920 | ||
|
|
631e9af470 | ||
|
|
b2fc56709a | ||
|
|
b928ed407b | ||
|
|
6bca0b3526 | ||
|
|
f45c275566 | ||
|
|
3e4312ca6f | ||
|
|
518b41e9cd | ||
|
|
1de7cda1b0 | ||
|
|
25c9eb52a0 | ||
|
|
2df636e454 | ||
|
|
575343dc19 | ||
|
|
1c641b4911 | ||
|
|
214e1e49f8 | ||
|
|
648dcf386e |
@@ -91,7 +91,6 @@ pub fn store_device_keys(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Load the signing private key for a device.
|
/// Load the signing private key for a device.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
|
pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
|
||||||
let path = device_dir(name)?.join("signing.key");
|
let path = device_dir(name)?.join("signing.key");
|
||||||
let key = fs::read_to_string(&path)
|
let key = fs::read_to_string(&path)
|
||||||
@@ -100,7 +99,6 @@ pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Load the deploy private key for a device.
|
/// Load the deploy private key for a device.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn load_deploy_key(name: &str) -> Result<Zeroizing<String>> {
|
pub fn load_deploy_key(name: &str) -> Result<Zeroizing<String>> {
|
||||||
let path = device_dir(name)?.join("deploy.key");
|
let path = device_dir(name)?.join("deploy.key");
|
||||||
let key = fs::read_to_string(&path)
|
let key = fs::read_to_string(&path)
|
||||||
@@ -117,7 +115,6 @@ pub fn load_gitea_key_id(name: &str) -> Result<u64> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete the local key directory for a device.
|
/// Delete the local key directory for a device.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn delete_device_keys(name: &str) -> Result<()> {
|
pub fn delete_device_keys(name: &str) -> Result<()> {
|
||||||
let dir = device_dir(name)?;
|
let dir = device_dir(name)?;
|
||||||
if dir.exists() {
|
if dir.exists() {
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ struct CreateKeyRequest<'a> {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct DeployKey {
|
pub struct DeployKey {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
#[allow(dead_code)]
|
|
||||||
pub title: String,
|
pub title: String,
|
||||||
#[allow(dead_code)]
|
|
||||||
pub key: String,
|
pub key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +89,6 @@ impl GiteaClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// List all deploy keys.
|
/// List all deploy keys.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn list_deploy_keys(&self) -> Result<Vec<DeployKey>> {
|
pub fn list_deploy_keys(&self) -> Result<Vec<DeployKey>> {
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{}/repos/{}/{}/keys",
|
"{}/repos/{}/{}/keys",
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ pub fn vault_dir() -> Result<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Path to the `.relicario/` configuration directory within the vault.
|
/// Path to the `.relicario/` configuration directory within the vault.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn relicario_dir() -> Result<PathBuf> {
|
pub fn relicario_dir() -> Result<PathBuf> {
|
||||||
Ok(vault_dir()?.join(".relicario"))
|
Ok(vault_dir()?.join(".relicario"))
|
||||||
}
|
}
|
||||||
@@ -89,21 +88,19 @@ fn plural(n: i64) -> &'static str { if n == 1 { "" } else { "s" } }
|
|||||||
///
|
///
|
||||||
/// **Plaintext leak:** group names land on disk in cleartext alongside the
|
/// **Plaintext leak:** group names land on disk in cleartext alongside the
|
||||||
/// vault directory. This is intentional — the file feeds shell completion,
|
/// vault directory. This is intentional — the file feeds shell completion,
|
||||||
/// which cannot prompt for a passphrase. In debug builds, set
|
/// which cannot prompt for a passphrase. Set `RELICARIO_NO_GROUPS_CACHE=1`
|
||||||
/// `RELICARIO_NO_GROUPS_CACHE=1` to suppress the write.
|
/// to suppress the write.
|
||||||
pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
|
pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
|
||||||
vault_dir.join(".relicario").join("groups.cache")
|
vault_dir.join(".relicario").join("groups.cache")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
|
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
|
||||||
/// one name per line. In debug builds, setting `RELICARIO_NO_GROUPS_CACHE`
|
/// one name per line. A no-op if `RELICARIO_NO_GROUPS_CACHE` is set.
|
||||||
/// suppresses the write (developer debugging tool). In release builds the env
|
|
||||||
/// var is ignored.
|
|
||||||
pub fn write_groups_cache(
|
pub fn write_groups_cache(
|
||||||
vault_dir: &Path,
|
vault_dir: &Path,
|
||||||
groups: &std::collections::BTreeSet<String>,
|
groups: &std::collections::BTreeSet<String>,
|
||||||
) -> std::io::Result<()> {
|
) -> std::io::Result<()> {
|
||||||
if cfg!(debug_assertions) && std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() {
|
if std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let path = groups_cache_path(vault_dir);
|
let path = groups_cache_path(vault_dir);
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ enum Commands {
|
|||||||
///
|
///
|
||||||
/// For `--group <TAB>` autocomplete, the bash/zsh/fish scripts read
|
/// For `--group <TAB>` autocomplete, the bash/zsh/fish scripts read
|
||||||
/// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file,
|
/// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file,
|
||||||
/// which the CLI refreshes on every manifest read. In debug builds, set
|
/// which the CLI refreshes on every manifest read. Set
|
||||||
/// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion
|
/// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion
|
||||||
/// will fall back to no value enumeration).
|
/// will fall back to no value enumeration).
|
||||||
///
|
///
|
||||||
@@ -540,7 +540,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
|||||||
};
|
};
|
||||||
let carrier = fs::read(&image)
|
let carrier = fs::read(&image)
|
||||||
.with_context(|| format!("failed to read carrier image {}", image.display()))?;
|
.with_context(|| format!("failed to read carrier image {}", image.display()))?;
|
||||||
let stego = imgsecret::embed(&carrier, &image_secret)?;
|
let stego = imgsecret::embed(&carrier, &*image_secret)?;
|
||||||
fs::write(&output, &stego)
|
fs::write(&output, &stego)
|
||||||
.with_context(|| format!("failed to write reference image {}", output.display()))?;
|
.with_context(|| format!("failed to write reference image {}", output.display()))?;
|
||||||
|
|
||||||
@@ -550,7 +550,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
|||||||
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
|
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
|
||||||
|
|
||||||
// Derive master key, then persist an empty Manifest + default VaultSettings.
|
// Derive master key, then persist an empty Manifest + default VaultSettings.
|
||||||
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)?;
|
let master_key = derive_master_key(passphrase.as_bytes(), &*image_secret, &salt, ¶ms)?;
|
||||||
|
|
||||||
fs::create_dir_all(&relicario_dir)?;
|
fs::create_dir_all(&relicario_dir)?;
|
||||||
fs::create_dir_all(root.join("items"))?;
|
fs::create_dir_all(root.join("items"))?;
|
||||||
@@ -645,7 +645,6 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
|||||||
// (for attachment-cap settings + writing the encrypted blob alongside
|
// (for attachment-cap settings + writing the encrypted blob alongside
|
||||||
// the item).
|
// the item).
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn build_login_item(
|
fn build_login_item(
|
||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
@@ -861,7 +860,6 @@ fn build_document_item(
|
|||||||
Ok(item)
|
Ok(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn build_totp_item(
|
fn build_totp_item(
|
||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
issuer: Option<String>,
|
issuer: Option<String>,
|
||||||
@@ -926,7 +924,7 @@ fn prompt_optional(label: &str) -> Result<Option<String>> {
|
|||||||
|
|
||||||
fn parse_month_year(s: &str) -> Result<relicario_core::MonthYear> {
|
fn parse_month_year(s: &str) -> Result<relicario_core::MonthYear> {
|
||||||
// Accepts MM/YYYY or MM-YYYY or MM/YY.
|
// Accepts MM/YYYY or MM-YYYY or MM/YY.
|
||||||
let (m_str, y_str) = s.split_once(['/', '-'])
|
let (m_str, y_str) = s.split_once(|c: char| c == '/' || c == '-')
|
||||||
.ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?;
|
.ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?;
|
||||||
let month: u8 = m_str.parse().context("invalid month")?;
|
let month: u8 = m_str.parse().context("invalid month")?;
|
||||||
let year: u16 = if y_str.len() == 2 {
|
let year: u16 = if y_str.len() == 2 {
|
||||||
@@ -1000,12 +998,12 @@ fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
|
|||||||
if let Some(u) = &l.url { println!("URL: {u}"); }
|
if let Some(u) = &l.url { println!("URL: {u}"); }
|
||||||
if let Some(t) = &l.totp {
|
if let Some(t) = &l.totp {
|
||||||
if show {
|
if show {
|
||||||
println!("TOTP: {}", data_encoding::BASE32.encode(&t.secret));
|
println!("TOTP: {}", data_encoding::BASE32.encode(&*t.secret));
|
||||||
} else {
|
} else {
|
||||||
println!("TOTP: **** (use --show to reveal)");
|
println!("TOTP: **** (use --show to reveal)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
l.password.clone()
|
if let Some(p) = &l.password { Some(p.clone()) } else { None }
|
||||||
}
|
}
|
||||||
ItemCore::SecureNote(n) => {
|
ItemCore::SecureNote(n) => {
|
||||||
if show { println!("Body:\n{}", n.body.as_str()); }
|
if show { println!("Body:\n{}", n.body.as_str()); }
|
||||||
@@ -1127,8 +1125,8 @@ fn cmd_list(
|
|||||||
Some(t) => e.r#type == t,
|
Some(t) => e.r#type == t,
|
||||||
None => true,
|
None => true,
|
||||||
})
|
})
|
||||||
.filter(|e| group_filter.as_ref().is_none_or(|g| e.group.as_deref() == Some(g.as_str())))
|
.filter(|e| group_filter.as_ref().map_or(true, |g| e.group.as_deref() == Some(g.as_str())))
|
||||||
.filter(|e| tag_filter.as_ref().is_none_or(|t| e.tags.iter().any(|x| x == t)))
|
.filter(|e| tag_filter.as_ref().map_or(true, |t| e.tags.iter().any(|x| x == t)))
|
||||||
.collect();
|
.collect();
|
||||||
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
|
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
|
||||||
|
|
||||||
@@ -1137,7 +1135,7 @@ fn cmd_list(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("{:<16} {:<14} {:<6} TITLE", "ID", "TYPE", "FAV");
|
println!("{:<16} {:<14} {:<6} {}", "ID", "TYPE", "FAV", "TITLE");
|
||||||
for e in entries {
|
for e in entries {
|
||||||
let fav = if e.favorite { " *" } else { "" };
|
let fav = if e.favorite { " *" } else { "" };
|
||||||
println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title);
|
println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title);
|
||||||
@@ -1720,32 +1718,9 @@ fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
|
|||||||
|
|
||||||
// .git/ history.
|
// .git/ history.
|
||||||
if let Some(tar_bytes) = &unpacked.git_archive {
|
if let Some(tar_bytes) = &unpacked.git_archive {
|
||||||
// Cap: 100× the compressed bundle size, or 1 GiB, whichever is lower.
|
let mut archive = tar::Archive::new(tar_bytes.as_slice());
|
||||||
let cap = std::cmp::min(
|
archive.unpack(target.join(".git"))
|
||||||
(tar_bytes.len() as u64).saturating_mul(100),
|
.with_context(|| "failed to untar .git/")?;
|
||||||
relicario_core::DEFAULT_MAX_UNCOMPRESSED,
|
|
||||||
);
|
|
||||||
let entries = relicario_core::safe_unpack_git_archive(tar_bytes, cap)
|
|
||||||
.with_context(|| "failed to safely unpack .git/ archive")?;
|
|
||||||
let git_dir = target.join(".git");
|
|
||||||
for (rel_path, body) in entries {
|
|
||||||
let dest = git_dir.join(&rel_path);
|
|
||||||
// Paranoid OS-level check even after textual validation in core.
|
|
||||||
if !dest.starts_with(&git_dir) {
|
|
||||||
anyhow::bail!(
|
|
||||||
"tar entry {} resolved outside .git/ (path traversal blocked)",
|
|
||||||
rel_path.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(parent) = dest.parent() {
|
|
||||||
fs::create_dir_all(parent).with_context(|| {
|
|
||||||
format!("create parent {}", parent.display())
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
fs::write(&dest, &body).with_context(|| {
|
|
||||||
format!("write {}", dest.display())
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// No history bundled — start a fresh git repo.
|
// No history bundled — start a fresh git repo.
|
||||||
let status = crate::helpers::git_command(&target, &["init"]).status()?;
|
let status = crate::helpers::git_command(&target, &["init"]).status()?;
|
||||||
@@ -1975,7 +1950,7 @@ fn cmd_attachments(query: String) -> Result<()> {
|
|||||||
let entry = resolve_query(&manifest, &query)?;
|
let entry = resolve_query(&manifest, &query)?;
|
||||||
let item = vault.load_item(&entry.id)?;
|
let item = vault.load_item(&entry.id)?;
|
||||||
if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); }
|
if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); }
|
||||||
println!("{:<17} {:>12} {:<22} FILENAME", "AID", "SIZE", "MIME");
|
println!("{:<17} {:>12} {:<22} {}", "AID", "SIZE", "MIME", "FILENAME");
|
||||||
for a in &item.attachments {
|
for a in &item.attachments {
|
||||||
println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename);
|
println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename);
|
||||||
}
|
}
|
||||||
@@ -2543,7 +2518,7 @@ fn cmd_device(action: DeviceAction) -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("{:<20} {:<20} SIGNING KEY (prefix)", "NAME", "ADDED");
|
println!("{:<20} {:<20} {}", "NAME", "ADDED", "SIGNING KEY (prefix)");
|
||||||
println!("{}", "-".repeat(72));
|
println!("{}", "-".repeat(72));
|
||||||
for d in &devices {
|
for d in &devices {
|
||||||
let marker = if d.name == current { " *" } else { "" };
|
let marker = if d.name == current { " *" } else { "" };
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ impl UnlockedVault {
|
|||||||
|
|
||||||
let master_key = derive_master_key(
|
let master_key = derive_master_key(
|
||||||
passphrase.as_bytes(),
|
passphrase.as_bytes(),
|
||||||
&image_secret,
|
&*image_secret,
|
||||||
&salt,
|
&salt,
|
||||||
¶ms,
|
¶ms,
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ fn detach_removes_attachment_and_blob() {
|
|||||||
// Encrypted blob file is gone.
|
// Encrypted blob file is gone.
|
||||||
let blob_path = v.path()
|
let blob_path = v.path()
|
||||||
.join("attachments")
|
.join("attachments")
|
||||||
.join("");
|
.join(stdout.lines().nth(1).is_some().then_some("").unwrap_or(""));
|
||||||
let item_attach_dir = std::fs::read_dir(v.path().join("attachments"))
|
let item_attach_dir = std::fs::read_dir(v.path().join("attachments"))
|
||||||
.unwrap().next().unwrap().unwrap().path();
|
.unwrap().next().unwrap().unwrap().path();
|
||||||
let blob = item_attach_dir.join(format!("{aid}.enc"));
|
let blob = item_attach_dir.join(format!("{aid}.enc"));
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ impl TestVault {
|
|||||||
cmd.output().unwrap()
|
cmd.output().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn run_with_backup_pass(&self, args: &[&str], backup_pass: &str) -> std::process::Output {
|
pub fn run_with_backup_pass(&self, args: &[&str], backup_pass: &str) -> std::process::Output {
|
||||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||||
cmd.current_dir(self.dir.path())
|
cmd.current_dir(self.dir.path())
|
||||||
@@ -92,7 +91,6 @@ impl TestVault {
|
|||||||
cmd.output().unwrap()
|
cmd.output().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output {
|
pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output {
|
||||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||||
cmd.current_dir(self.dir.path())
|
cmd.current_dir(self.dir.path())
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ mod tests {
|
|||||||
blob.extend_from_slice(&[0u8; 16]);
|
blob.extend_from_slice(&[0u8; 16]);
|
||||||
|
|
||||||
let key = Zeroizing::new([0u8; 32]);
|
let key = Zeroizing::new([0u8; 32]);
|
||||||
let err = decrypt(&key, &blob).expect_err("v1 blob should fail decrypt");
|
let err = decrypt(&*key, &blob).expect_err("v1 blob should fail decrypt");
|
||||||
match err {
|
match err {
|
||||||
RelicarioError::UnsupportedFormatVersion { found, expected } => {
|
RelicarioError::UnsupportedFormatVersion { found, expected } => {
|
||||||
assert_eq!(found, 0x01);
|
assert_eq!(found, 0x01);
|
||||||
|
|||||||
@@ -106,16 +106,6 @@ pub fn verify(public_key_openssh: &str, data: &[u8], signature_b64: &str) -> Res
|
|||||||
Ok(verifying_key.verify(data, &signature).is_ok())
|
Ok(verifying_key.verify(data, &signature).is_ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the OpenSSH SHA-256 fingerprint of a public key.
|
|
||||||
/// Output format matches `ssh-keygen -lf` and `git verify-commit --raw`:
|
|
||||||
/// `SHA256:<43-char base64 without padding>`.
|
|
||||||
pub fn fingerprint(public_key_openssh: &str) -> Result<String> {
|
|
||||||
use ssh_key::HashAlg;
|
|
||||||
let public = PublicKey::from_openssh(public_key_openssh)
|
|
||||||
.map_err(|e| RelicarioError::DeviceKey(format!("parse public key: {e}")))?;
|
|
||||||
Ok(public.fingerprint(HashAlg::Sha256).to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -142,27 +132,4 @@ mod tests {
|
|||||||
let sig = sign(&private, b"hello").unwrap();
|
let sig = sign(&private, b"hello").unwrap();
|
||||||
assert!(!verify(&other_public, b"hello", &sig).unwrap());
|
assert!(!verify(&other_public, b"hello", &sig).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fingerprint_matches_ssh_keygen_format() {
|
|
||||||
let (_, public) = generate_keypair().unwrap();
|
|
||||||
let fp = fingerprint(&public).unwrap();
|
|
||||||
assert!(fp.starts_with("SHA256:"), "fingerprint should start with SHA256: prefix, got {fp}");
|
|
||||||
let body = fp.strip_prefix("SHA256:").unwrap();
|
|
||||||
assert_eq!(body.len(), 43, "SHA-256 fingerprint body is 43 base64 chars (no padding)");
|
|
||||||
assert!(body.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/'));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fingerprint_is_deterministic() {
|
|
||||||
let (_, public) = generate_keypair().unwrap();
|
|
||||||
assert_eq!(fingerprint(&public).unwrap(), fingerprint(&public).unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fingerprint_differs_per_key() {
|
|
||||||
let (_, p1) = generate_keypair().unwrap();
|
|
||||||
let (_, p2) = generate_keypair().unwrap();
|
|
||||||
assert_ne!(fingerprint(&p1).unwrap(), fingerprint(&p2).unwrap());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,10 +51,6 @@ pub enum RelicarioError {
|
|||||||
#[error("backup envelope schema v{found}; this Relicario reads v{expected}")]
|
#[error("backup envelope schema v{found}; this Relicario reads v{expected}")]
|
||||||
BackupSchemaMismatch { found: u32, expected: u32 },
|
BackupSchemaMismatch { found: u32, expected: u32 },
|
||||||
|
|
||||||
/// An error during backup restore (e.g., tar safety validation failure).
|
|
||||||
#[error("backup restore: {0}")]
|
|
||||||
BackupRestore(String),
|
|
||||||
|
|
||||||
/// CSV header doesn't match the LastPass column layout.
|
/// CSV header doesn't match the LastPass column layout.
|
||||||
#[error("unrecognized CSV header — expected LastPass export format ({0})")]
|
#[error("unrecognized CSV header — expected LastPass export format ({0})")]
|
||||||
ImportCsvHeader(String),
|
ImportCsvHeader(String),
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ const BITS_PER_BLOCK: usize = 12; // EMBED_POSITIONS.len()
|
|||||||
|
|
||||||
/// Number of 8x8 blocks needed to hold one complete copy of the 256-bit secret.
|
/// Number of 8x8 blocks needed to hold one complete copy of the 256-bit secret.
|
||||||
/// ceil(256 / 12) = 22 blocks per copy.
|
/// ceil(256 / 12) = 22 blocks per copy.
|
||||||
const BLOCKS_PER_COPY: usize = SECRET_BITS.div_ceil(BITS_PER_BLOCK); // 22
|
const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // 22
|
||||||
|
|
||||||
/// Mid-frequency DCT coefficient positions for embedding, specified as
|
/// Mid-frequency DCT coefficient positions for embedding, specified as
|
||||||
/// (row, col) indices into the 8x8 DCT coefficient matrix.
|
/// (row, col) indices into the 8x8 DCT coefficient matrix.
|
||||||
@@ -302,9 +302,9 @@ fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let mut block = [[0.0f64; 8]; 8];
|
let mut block = [[0.0f64; 8]; 8];
|
||||||
for (row, block_row) in block.iter_mut().enumerate() {
|
for row in 0..8 {
|
||||||
for (col, cell) in block_row.iter_mut().enumerate() {
|
for col in 0..8 {
|
||||||
*cell = y.get(px + col, py + row);
|
block[row][col] = y.get(px + col, py + row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(block)
|
Some(block)
|
||||||
@@ -323,9 +323,9 @@ fn read_block(y: &YChannel, bx: usize, by: usize, region: &EmbedRegion) -> [[f64
|
|||||||
fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) {
|
fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) {
|
||||||
let start_x = region.x_offset + bx * BLOCK_SIZE;
|
let start_x = region.x_offset + bx * BLOCK_SIZE;
|
||||||
let start_y = region.y_offset + by * BLOCK_SIZE;
|
let start_y = region.y_offset + by * BLOCK_SIZE;
|
||||||
for (row, block_row) in block.iter().enumerate() {
|
for row in 0..8 {
|
||||||
for (col, &cell) in block_row.iter().enumerate() {
|
for col in 0..8 {
|
||||||
y.set(start_x + col, start_y + row, cell);
|
y.set(start_x + col, start_y + row, block[row][col]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,17 +349,17 @@ fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, blo
|
|||||||
/// where c(0) = sqrt(1/8) and c(k) = sqrt(2/8) for k > 0.
|
/// where c(0) = sqrt(1/8) and c(k) = sqrt(2/8) for k > 0.
|
||||||
fn dct1d(input: &[f64; 8]) -> [f64; 8] {
|
fn dct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||||
let mut output = [0.0f64; 8];
|
let mut output = [0.0f64; 8];
|
||||||
for (k, out_k) in output.iter_mut().enumerate() {
|
for k in 0..8 {
|
||||||
let ck = if k == 0 {
|
let ck = if k == 0 {
|
||||||
(1.0 / 8.0_f64).sqrt()
|
(1.0 / 8.0_f64).sqrt()
|
||||||
} else {
|
} else {
|
||||||
(2.0 / 8.0_f64).sqrt()
|
(2.0 / 8.0_f64).sqrt()
|
||||||
};
|
};
|
||||||
let mut sum = 0.0;
|
let mut sum = 0.0;
|
||||||
for (i, &x) in input.iter().enumerate() {
|
for i in 0..8 {
|
||||||
sum += x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
sum += input[i] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
||||||
}
|
}
|
||||||
*out_k = ck * sum;
|
output[k] = ck * sum;
|
||||||
}
|
}
|
||||||
output
|
output
|
||||||
}
|
}
|
||||||
@@ -370,17 +370,17 @@ fn dct1d(input: &[f64; 8]) -> [f64; 8] {
|
|||||||
/// x[i] = sum_{k=0}^{7} c(k) * X[k] * cos((2i+1)*k*pi/16)
|
/// x[i] = sum_{k=0}^{7} c(k) * X[k] * cos((2i+1)*k*pi/16)
|
||||||
fn idct1d(input: &[f64; 8]) -> [f64; 8] {
|
fn idct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||||
let mut output = [0.0f64; 8];
|
let mut output = [0.0f64; 8];
|
||||||
for (i, out_i) in output.iter_mut().enumerate() {
|
for i in 0..8 {
|
||||||
let mut sum = 0.0;
|
let mut sum = 0.0;
|
||||||
for (k, &x) in input.iter().enumerate() {
|
for k in 0..8 {
|
||||||
let ck = if k == 0 {
|
let ck = if k == 0 {
|
||||||
(1.0 / 8.0_f64).sqrt()
|
(1.0 / 8.0_f64).sqrt()
|
||||||
} else {
|
} else {
|
||||||
(2.0 / 8.0_f64).sqrt()
|
(2.0 / 8.0_f64).sqrt()
|
||||||
};
|
};
|
||||||
sum += ck * x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
sum += ck * input[k] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
||||||
}
|
}
|
||||||
*out_i = sum;
|
output[i] = sum;
|
||||||
}
|
}
|
||||||
output
|
output
|
||||||
}
|
}
|
||||||
@@ -501,7 +501,7 @@ fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
|
|||||||
///
|
///
|
||||||
/// Pads the last byte with zeros if the bit count is not a multiple of 8.
|
/// Pads the last byte with zeros if the bit count is not a multiple of 8.
|
||||||
fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
|
fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
|
||||||
let mut bytes = Vec::with_capacity(bits.len().div_ceil(8));
|
let mut bytes = Vec::with_capacity((bits.len() + 7) / 8);
|
||||||
for chunk in bits.chunks(8) {
|
for chunk in bits.chunks(8) {
|
||||||
let mut byte = 0u8;
|
let mut byte = 0u8;
|
||||||
for (i, &bit) in chunk.iter().enumerate() {
|
for (i, &bit) in chunk.iter().enumerate() {
|
||||||
|
|||||||
@@ -52,15 +52,18 @@ pub enum TotpAlgorithm {
|
|||||||
Sha512,
|
Sha512,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum TotpKind {
|
pub enum TotpKind {
|
||||||
#[default]
|
|
||||||
Totp,
|
Totp,
|
||||||
Hotp { counter: u64 },
|
Hotp { counter: u64 },
|
||||||
Steam,
|
Steam,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for TotpKind {
|
||||||
|
fn default() -> Self { TotpKind::Totp }
|
||||||
|
}
|
||||||
|
|
||||||
/// Compute a TOTP/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`.
|
||||||
|
|||||||
@@ -85,7 +85,4 @@ pub mod import_lastpass;
|
|||||||
pub use import_lastpass::{parse_lastpass_csv, ImportWarning};
|
pub use import_lastpass::{parse_lastpass_csv, ImportWarning};
|
||||||
|
|
||||||
pub mod device;
|
pub mod device;
|
||||||
pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
|
pub use device::{DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
|
||||||
|
|
||||||
pub mod tar_safe;
|
|
||||||
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
|
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
//! Safe tar unpacking for backup restore.
|
|
||||||
//!
|
|
||||||
//! The standard `tar::Archive::unpack` has no guards against path traversal,
|
|
||||||
//! absolute paths, symlinks, hardlinks, or tar bombs. This module replaces it
|
|
||||||
//! with `safe_unpack_git_archive`, which validates every entry before returning
|
|
||||||
//! `(relative_path, bytes)` pairs to the caller.
|
|
||||||
|
|
||||||
use std::io::Read;
|
|
||||||
use std::path::{Component, PathBuf};
|
|
||||||
|
|
||||||
use tar::EntryType;
|
|
||||||
|
|
||||||
use crate::error::{RelicarioError, Result};
|
|
||||||
|
|
||||||
/// Default cap on total uncompressed bytes extracted in one restore (1 GiB).
|
|
||||||
pub const DEFAULT_MAX_UNCOMPRESSED: u64 = 1024 * 1024 * 1024;
|
|
||||||
|
|
||||||
/// Decode `tar_bytes` and return `(relative_path, file_bytes)` pairs for
|
|
||||||
/// regular files only.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns `Err(RelicarioError::BackupRestore(...))` if:
|
|
||||||
///
|
|
||||||
/// - Any path component is `..` (`Component::ParentDir`) — "path traversal blocked".
|
|
||||||
/// - Any path starts with `/` (`Component::RootDir`) — "path traversal blocked".
|
|
||||||
/// - Any path has a Windows drive prefix (`Component::Prefix`) — "path traversal blocked".
|
|
||||||
/// - An entry is a symlink or hardlink — "symlink/link rejected".
|
|
||||||
/// - An entry's declared size exceeds `max_uncompressed_bytes` — "size cap exceeded".
|
|
||||||
/// - The running total of all entry sizes exceeds `max_uncompressed_bytes` — "size cap exceeded".
|
|
||||||
/// - An entry has an unexpected type (not regular file, not directory) — "unexpected entry type".
|
|
||||||
pub fn safe_unpack_git_archive(
|
|
||||||
tar_bytes: &[u8],
|
|
||||||
max_uncompressed_bytes: u64,
|
|
||||||
) -> Result<Vec<(PathBuf, Vec<u8>)>> {
|
|
||||||
let mut archive = tar::Archive::new(tar_bytes);
|
|
||||||
let entries = archive
|
|
||||||
.entries()
|
|
||||||
.map_err(|e| RelicarioError::BackupRestore(format!("failed to read tar entries: {e}")))?;
|
|
||||||
|
|
||||||
let mut result: Vec<(PathBuf, Vec<u8>)> = Vec::new();
|
|
||||||
let mut cumulative: u64 = 0;
|
|
||||||
|
|
||||||
for entry in entries {
|
|
||||||
let mut entry = entry.map_err(|e| {
|
|
||||||
RelicarioError::BackupRestore(format!("failed to read tar entry: {e}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let header = entry.header();
|
|
||||||
let entry_type = header.entry_type();
|
|
||||||
|
|
||||||
// Reject symlinks and hardlinks.
|
|
||||||
match entry_type {
|
|
||||||
EntryType::Symlink => {
|
|
||||||
return Err(RelicarioError::BackupRestore(
|
|
||||||
"symlink entry rejected".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
EntryType::Link => {
|
|
||||||
return Err(RelicarioError::BackupRestore(
|
|
||||||
"hardlink entry rejected".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
EntryType::Directory => {
|
|
||||||
// Directories are implicit — skip without reading body.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
EntryType::Regular | EntryType::Continuous | EntryType::GNUSparse => {
|
|
||||||
// These are normal file types; fall through to path checks.
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(RelicarioError::BackupRestore(format!(
|
|
||||||
"unexpected entry type: {:?}",
|
|
||||||
entry_type
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate the path.
|
|
||||||
let path = entry.path().map_err(|e| {
|
|
||||||
RelicarioError::BackupRestore(format!("invalid path in tar entry: {e}"))
|
|
||||||
})?;
|
|
||||||
let path = path.into_owned();
|
|
||||||
|
|
||||||
for component in path.components() {
|
|
||||||
match component {
|
|
||||||
Component::ParentDir => {
|
|
||||||
return Err(RelicarioError::BackupRestore(
|
|
||||||
"path traversal blocked: entry contains '..' component".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Component::RootDir => {
|
|
||||||
return Err(RelicarioError::BackupRestore(
|
|
||||||
"path traversal blocked: entry has absolute path".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Component::Prefix(_) => {
|
|
||||||
return Err(RelicarioError::BackupRestore(
|
|
||||||
"path traversal blocked: entry has Windows drive prefix".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Component::Normal(_) | Component::CurDir => {
|
|
||||||
// Acceptable components.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check declared size before reading body.
|
|
||||||
let claimed = header.size().map_err(|e| {
|
|
||||||
RelicarioError::BackupRestore(format!("could not read entry size: {e}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if claimed > max_uncompressed_bytes {
|
|
||||||
return Err(RelicarioError::BackupRestore(format!(
|
|
||||||
"size cap exceeded: entry claims {claimed} bytes (cap {max_uncompressed_bytes})"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let new_total = cumulative.saturating_add(claimed);
|
|
||||||
if new_total > max_uncompressed_bytes {
|
|
||||||
return Err(RelicarioError::BackupRestore(format!(
|
|
||||||
"size cap exceeded: cumulative size would reach {new_total} bytes (cap {max_uncompressed_bytes})"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the file body.
|
|
||||||
let mut body = Vec::with_capacity(claimed as usize);
|
|
||||||
entry.read_to_end(&mut body).map_err(|e| {
|
|
||||||
RelicarioError::BackupRestore(format!("failed to read entry body: {e}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
cumulative += body.len() as u64;
|
|
||||||
|
|
||||||
result.push((path, body));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
@@ -19,7 +19,7 @@ impl MonthYear {
|
|||||||
if !(1..=12).contains(&month) {
|
if !(1..=12).contains(&month) {
|
||||||
return Err("month must be 1..=12");
|
return Err("month must be 1..=12");
|
||||||
}
|
}
|
||||||
if !(2000..=2099).contains(&year) {
|
if year < 2000 || year > 2099 {
|
||||||
return Err("year must be 2000..=2099");
|
return Err("year must be 2000..=2099");
|
||||||
}
|
}
|
||||||
Ok(Self { month, year })
|
Ok(Self { month, year })
|
||||||
|
|||||||
@@ -1,187 +0,0 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
use tar::{Builder, Header, EntryType};
|
|
||||||
use relicario_core::safe_unpack_git_archive;
|
|
||||||
|
|
||||||
/// Craft a raw POSIX ustar tar with a single entry using the given raw path bytes.
|
|
||||||
/// The tar crate's `Builder` sanitises paths, so we write the 512-byte header
|
|
||||||
/// manually to produce truly malicious archives.
|
|
||||||
fn raw_tar_with_path(raw_path: &[u8], content: &[u8]) -> Vec<u8> {
|
|
||||||
let mut buf = vec![0u8; 512]; // one header block
|
|
||||||
|
|
||||||
// Bytes 0-99: name field (null-padded)
|
|
||||||
let name_len = raw_path.len().min(100);
|
|
||||||
buf[..name_len].copy_from_slice(&raw_path[..name_len]);
|
|
||||||
|
|
||||||
// Bytes 100-107: mode = "0000644\0"
|
|
||||||
buf[100..108].copy_from_slice(b"0000644\0");
|
|
||||||
|
|
||||||
// Bytes 108-115: uid
|
|
||||||
buf[108..116].copy_from_slice(b"0000000\0");
|
|
||||||
|
|
||||||
// Bytes 116-123: gid
|
|
||||||
buf[116..124].copy_from_slice(b"0000000\0");
|
|
||||||
|
|
||||||
// Bytes 124-135: size (octal, 11 digits + null)
|
|
||||||
let size_str = format!("{:011o}\0", content.len());
|
|
||||||
buf[124..136].copy_from_slice(size_str.as_bytes());
|
|
||||||
|
|
||||||
// Bytes 136-147: mtime
|
|
||||||
buf[136..148].copy_from_slice(b"00000000000\0");
|
|
||||||
|
|
||||||
// Bytes 148-155: checksum placeholder (spaces during compute)
|
|
||||||
buf[148..156].copy_from_slice(b" ");
|
|
||||||
|
|
||||||
// Byte 156: typeflag = '0' (regular file)
|
|
||||||
buf[156] = b'0';
|
|
||||||
|
|
||||||
// Bytes 257-262: magic "ustar\0"
|
|
||||||
buf[257..263].copy_from_slice(b"ustar\0");
|
|
||||||
// Bytes 263-264: version "00"
|
|
||||||
buf[263..265].copy_from_slice(b"00");
|
|
||||||
|
|
||||||
// Compute checksum (sum of all bytes, checksum field treated as spaces).
|
|
||||||
let checksum: u32 = buf.iter().map(|&b| b as u32).sum();
|
|
||||||
let cksum_str = format!("{:06o}\0 ", checksum);
|
|
||||||
buf[148..156].copy_from_slice(cksum_str.as_bytes());
|
|
||||||
|
|
||||||
// Append padded content blocks.
|
|
||||||
let mut out = buf;
|
|
||||||
if !content.is_empty() {
|
|
||||||
out.extend_from_slice(content);
|
|
||||||
// Pad to 512-byte boundary.
|
|
||||||
let remainder = content.len() % 512;
|
|
||||||
if remainder != 0 {
|
|
||||||
out.extend(vec![0u8; 512 - remainder]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two zero blocks = end-of-archive.
|
|
||||||
out.extend(vec![0u8; 1024]);
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a tar with a raw symlink entry (typeflag = '2').
|
|
||||||
fn raw_symlink_tar() -> Vec<u8> {
|
|
||||||
let mut buf = vec![0u8; 512];
|
|
||||||
|
|
||||||
// name
|
|
||||||
buf[..9].copy_from_slice(b"evil_link");
|
|
||||||
// mode
|
|
||||||
buf[100..108].copy_from_slice(b"0000755\0");
|
|
||||||
// uid/gid
|
|
||||||
buf[108..116].copy_from_slice(b"0000000\0");
|
|
||||||
buf[116..124].copy_from_slice(b"0000000\0");
|
|
||||||
// size = 0
|
|
||||||
buf[124..136].copy_from_slice(b"00000000000\0");
|
|
||||||
// mtime
|
|
||||||
buf[136..148].copy_from_slice(b"00000000000\0");
|
|
||||||
// checksum placeholder
|
|
||||||
buf[148..156].copy_from_slice(b" ");
|
|
||||||
// typeflag = '2' (symlink)
|
|
||||||
buf[156] = b'2';
|
|
||||||
// linkname
|
|
||||||
let target = b"/etc/passwd";
|
|
||||||
buf[157..157 + target.len()].copy_from_slice(target);
|
|
||||||
// magic
|
|
||||||
buf[257..263].copy_from_slice(b"ustar\0");
|
|
||||||
buf[263..265].copy_from_slice(b"00");
|
|
||||||
|
|
||||||
// Compute checksum.
|
|
||||||
let checksum: u32 = buf.iter().map(|&b| b as u32).sum();
|
|
||||||
let cksum_str = format!("{:06o}\0 ", checksum);
|
|
||||||
buf[148..156].copy_from_slice(cksum_str.as_bytes());
|
|
||||||
|
|
||||||
let mut out = buf;
|
|
||||||
out.extend(vec![0u8; 1024]); // end-of-archive
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_normal_tar() -> Vec<u8> {
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
{
|
|
||||||
let mut builder = Builder::new(&mut buf);
|
|
||||||
let content = b"hello";
|
|
||||||
let mut header = Header::new_gnu();
|
|
||||||
header.set_entry_type(EntryType::Regular);
|
|
||||||
header.set_size(content.len() as u64);
|
|
||||||
header.set_cksum();
|
|
||||||
builder
|
|
||||||
.append_data(&mut header, "subdir/hello.txt", content.as_ref())
|
|
||||||
.unwrap();
|
|
||||||
builder.finish().unwrap();
|
|
||||||
}
|
|
||||||
buf
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_oversize_tar() -> Vec<u8> {
|
|
||||||
// Actual 2048-byte body; test will use cap=1024
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
{
|
|
||||||
let mut builder = Builder::new(&mut buf);
|
|
||||||
let content = vec![0u8; 2048];
|
|
||||||
let mut header = Header::new_gnu();
|
|
||||||
header.set_entry_type(EntryType::Regular);
|
|
||||||
header.set_size(content.len() as u64);
|
|
||||||
header.set_cksum();
|
|
||||||
builder
|
|
||||||
.append_data(&mut header, "bigfile.bin", content.as_slice())
|
|
||||||
.unwrap();
|
|
||||||
builder.finish().unwrap();
|
|
||||||
}
|
|
||||||
buf
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn restore_rejects_path_traversal() {
|
|
||||||
// Craft a tar with "../../escaped.txt" using raw bytes (Builder sanitises paths).
|
|
||||||
let bytes = raw_tar_with_path(b"../../escaped.txt", b"evil content");
|
|
||||||
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
|
|
||||||
let msg = format!("{err:#}");
|
|
||||||
assert!(
|
|
||||||
msg.contains("path traversal") || msg.contains(".."),
|
|
||||||
"got: {msg}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn restore_rejects_absolute_path() {
|
|
||||||
// Craft a tar with "/etc/escaped.txt" using raw bytes.
|
|
||||||
let bytes = raw_tar_with_path(b"/etc/escaped.txt", b"evil content");
|
|
||||||
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
|
|
||||||
let msg = format!("{err:#}");
|
|
||||||
assert!(
|
|
||||||
msg.contains("path traversal") || msg.contains("absolute"),
|
|
||||||
"got: {msg}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn restore_rejects_symlink() {
|
|
||||||
let bytes = raw_symlink_tar();
|
|
||||||
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
|
|
||||||
let msg = format!("{err:#}");
|
|
||||||
assert!(
|
|
||||||
msg.contains("symlink") || msg.contains("link"),
|
|
||||||
"got: {msg}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn restore_rejects_size_bomb() {
|
|
||||||
let bytes = build_oversize_tar(); // actual 2048-byte entry
|
|
||||||
let err = safe_unpack_git_archive(&bytes, 1024).unwrap_err(); // cap = 1024 bytes
|
|
||||||
let msg = format!("{err:#}");
|
|
||||||
assert!(
|
|
||||||
msg.contains("size") || msg.contains("cap") || msg.contains("too large"),
|
|
||||||
"got: {msg}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn restore_accepts_normal_files() {
|
|
||||||
let buf = build_normal_tar();
|
|
||||||
let entries = safe_unpack_git_archive(&buf, 1024 * 1024).expect("happy path");
|
|
||||||
assert_eq!(entries.len(), 1);
|
|
||||||
assert_eq!(entries[0].0, PathBuf::from("subdir/hello.txt"));
|
|
||||||
assert_eq!(entries[0].1, b"hello");
|
|
||||||
}
|
|
||||||
@@ -9,10 +9,3 @@ anyhow = "1"
|
|||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tempfile = "3"
|
|
||||||
regex = "1"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
assert_cmd = "2"
|
|
||||||
predicates = "3"
|
|
||||||
tempfile = "3"
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! relicario-server -- pre-receive hook for signature verification.
|
//! relicario-server -- pre-receive hook for signature verification.
|
||||||
|
|
||||||
use std::fs;
|
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -35,120 +34,49 @@ fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn verify_commit(commit: &str) -> Result<()> {
|
fn verify_commit(commit: &str) -> Result<()> {
|
||||||
|
// Get devices.json at this commit
|
||||||
let devices_json = match git_show(commit, ".relicario/devices.json") {
|
let devices_json = match git_show(commit, ".relicario/devices.json") {
|
||||||
Ok(json) => json,
|
Ok(json) => json,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
eprintln!("OK: commit {commit} (bootstrap - no devices.json)");
|
// No devices.json yet -- bootstrap mode, allow unsigned
|
||||||
|
eprintln!("OK: commit {} (bootstrap - no devices.json)", commit);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let devices: Vec<DeviceEntry> = serde_json::from_str(&devices_json)
|
let devices: Vec<DeviceEntry> = serde_json::from_str(&devices_json)
|
||||||
.context("parse 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")
|
let revoked: Vec<RevokedEntry> = git_show(commit, ".relicario/revoked.json")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| serde_json::from_str(&s).ok())
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// True bootstrap: no devices ever registered and none revoked.
|
// Get commit signature
|
||||||
if devices.is_empty() && revoked.is_empty() {
|
|
||||||
eprintln!("OK: commit {commit} (bootstrap - no devices registered)");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build temp allowed-signers file from registered devices.
|
|
||||||
let tmp = tempfile::tempdir().context("create tempdir")?;
|
|
||||||
let allowed_path = tmp.path().join("allowed_signers");
|
|
||||||
let mut allowed_body = String::new();
|
|
||||||
for d in &devices {
|
|
||||||
allowed_body.push_str("relicario ");
|
|
||||||
allowed_body.push_str(d.public_key.trim());
|
|
||||||
allowed_body.push('\n');
|
|
||||||
}
|
|
||||||
fs::write(&allowed_path, &allowed_body).context("write allowed_signers")?;
|
|
||||||
|
|
||||||
// Run git verify-commit --raw. Capture both exit code and stderr.
|
|
||||||
// NOTE: we do NOT short-circuit on non-zero exit here because even for
|
|
||||||
// unregistered keys git still outputs "Good ... key SHA256:..." on stderr.
|
|
||||||
let output = Command::new("git")
|
let output = Command::new("git")
|
||||||
.args(["verify-commit", "--raw", commit])
|
.args(["verify-commit", "--raw", commit])
|
||||||
.env("GIT_CONFIG_COUNT", "1")
|
|
||||||
.env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
|
|
||||||
.env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
|
|
||||||
.output()
|
.output()
|
||||||
.context("git verify-commit")?;
|
.context("git verify-commit")?;
|
||||||
|
|
||||||
|
// Check if signed
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
if !stderr.contains("GOODSIG") && !stderr.contains("Good signature") {
|
||||||
// Parse the SHA-256 fingerprint from stderr.
|
eprintln!("REJECT: commit {} is not signed by a registered device", commit);
|
||||||
// SSH signature output: "Good "git" signature ... with ED25519 key SHA256:<base64>"
|
|
||||||
let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").expect("static regex");
|
|
||||||
let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) {
|
|
||||||
Some(m) => m.as_str().to_string(),
|
|
||||||
None => {
|
|
||||||
// No fingerprint in stderr = unsigned or completely malformed signature.
|
|
||||||
eprintln!(
|
|
||||||
"REJECT: commit {commit} — no valid signature found (stderr: {})",
|
|
||||||
stderr.trim()
|
|
||||||
);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build fingerprint → entry maps.
|
|
||||||
let mut device_by_fp: std::collections::HashMap<String, &DeviceEntry> =
|
|
||||||
std::collections::HashMap::new();
|
|
||||||
for d in &devices {
|
|
||||||
if let Ok(fp) = relicario_core::device::fingerprint(&d.public_key) {
|
|
||||||
device_by_fp.insert(fp, d);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut revoked_by_fp: std::collections::HashMap<String, &RevokedEntry> =
|
|
||||||
std::collections::HashMap::new();
|
|
||||||
for r in &revoked {
|
|
||||||
if let Ok(fp) = relicario_core::device::fingerprint(&r.public_key) {
|
|
||||||
revoked_by_fp.insert(fp, r);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get committer date (NOT author date).
|
|
||||||
let ct_out = Command::new("git")
|
|
||||||
.args(["show", "-s", "--format=%ct", commit])
|
|
||||||
.output()
|
|
||||||
.context("git show committer date")?;
|
|
||||||
let committer_ts: i64 = String::from_utf8_lossy(&ct_out.stdout)
|
|
||||||
.trim()
|
|
||||||
.parse()
|
|
||||||
.context("parse committer timestamp")?;
|
|
||||||
|
|
||||||
// Check revocation FIRST (revoked entries may not be in devices anymore).
|
|
||||||
if let Some(r) = revoked_by_fp.get(&signing_fp) {
|
|
||||||
if committer_ts >= r.revoked_at {
|
|
||||||
eprintln!(
|
|
||||||
"REJECT: commit {commit} — signed by revoked device '{}' \
|
|
||||||
(committer ts {committer_ts} >= revoked_at {})",
|
|
||||||
r.name, r.revoked_at
|
|
||||||
);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
// Historical commit: committer_ts < revoked_at → was valid when signed.
|
|
||||||
eprintln!(
|
|
||||||
"OK: commit {commit} — historical commit signed by '{}' before revocation",
|
|
||||||
r.name
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not revoked — must be in active devices.
|
|
||||||
if !device_by_fp.contains_key(&signing_fp) {
|
|
||||||
eprintln!(
|
|
||||||
"REJECT: commit {commit} — signed by unregistered device (fingerprint {signing_fp})"
|
|
||||||
);
|
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!("OK: commit {commit} verified (signed by '{}')", device_by_fp[&signing_fp].name);
|
// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
//! Acceptance tests for `relicario-server verify-commit`.
|
|
||||||
//!
|
|
||||||
//! Four scenarios from audit S1:
|
|
||||||
//! 1. Registered non-revoked key → exit 0
|
|
||||||
//! 2. Unregistered key → exit 1 (stderr contains "unregistered")
|
|
||||||
//! 3. Revoked key, commit AFTER revoked_at → exit 1 (stderr contains "revoked")
|
|
||||||
//! 4. Revoked key, commit BEFORE revoked_at (historical) → exit 0
|
|
||||||
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
use assert_cmd::Command as AssertCommand;
|
|
||||||
use predicates::prelude::*;
|
|
||||||
use relicario_core::device::{generate_keypair, DeviceEntry, RevokedEntry};
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
fn write_keypair(dir: &Path, name: &str) -> (PathBuf, PathBuf, String) {
|
|
||||||
let (priv_pem, pub_line) = generate_keypair().expect("generate keypair");
|
|
||||||
let priv_path = dir.join(format!("{name}.key"));
|
|
||||||
let pub_path = dir.join(format!("{name}.pub"));
|
|
||||||
fs::write(&priv_path, priv_pem.as_str()).unwrap();
|
|
||||||
fs::write(&pub_path, &pub_line).unwrap();
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap();
|
|
||||||
}
|
|
||||||
(priv_path, pub_path, pub_line)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn git(repo: &Path, args: &[&str], extra_env: &[(&str, &str)]) {
|
|
||||||
let mut cmd = Command::new("git");
|
|
||||||
cmd.current_dir(repo).args(args);
|
|
||||||
for (k, v) in extra_env {
|
|
||||||
cmd.env(k, v);
|
|
||||||
}
|
|
||||||
let status = cmd.status().expect("spawn git");
|
|
||||||
assert!(status.success(), "git {args:?} failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init_repo(repo: &Path) {
|
|
||||||
git(repo, &["init", "-q", "-b", "main"], &[]);
|
|
||||||
git(repo, &["config", "user.email", "test@test"], &[]);
|
|
||||||
git(repo, &["config", "user.name", "test"], &[]);
|
|
||||||
git(repo, &["commit", "--allow-empty", "-q", "-m", "init"], &[]);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sign_commit(
|
|
||||||
repo: &Path,
|
|
||||||
signing_key: &Path,
|
|
||||||
allowed_signers: &Path,
|
|
||||||
committer_unix: i64,
|
|
||||||
msg: &str,
|
|
||||||
file_path: &str,
|
|
||||||
file_content: &str,
|
|
||||||
) -> String {
|
|
||||||
fs::write(repo.join(file_path), file_content).unwrap();
|
|
||||||
git(repo, &["add", file_path], &[]);
|
|
||||||
let date = format!("@{committer_unix} +0000");
|
|
||||||
git(
|
|
||||||
repo,
|
|
||||||
&[
|
|
||||||
"-c", "gpg.format=ssh",
|
|
||||||
"-c", &format!("user.signingkey={}", signing_key.display()),
|
|
||||||
"-c", &format!("gpg.ssh.allowedSignersFile={}", allowed_signers.display()),
|
|
||||||
"commit", "-S", "-q", "-m", msg,
|
|
||||||
],
|
|
||||||
&[
|
|
||||||
("GIT_AUTHOR_DATE", &date),
|
|
||||||
("GIT_COMMITTER_DATE", &date),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
let out = Command::new("git")
|
|
||||||
.current_dir(repo)
|
|
||||||
.args(["rev-parse", "HEAD"])
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
String::from_utf8(out.stdout).unwrap().trim().to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_device_files(repo: &Path, devices: &[DeviceEntry], revoked: &[RevokedEntry]) {
|
|
||||||
let dir = repo.join(".relicario");
|
|
||||||
fs::create_dir_all(&dir).unwrap();
|
|
||||||
fs::write(dir.join("devices.json"), serde_json::to_string_pretty(devices).unwrap()).unwrap();
|
|
||||||
fs::write(dir.join("revoked.json"), serde_json::to_string_pretty(revoked).unwrap()).unwrap();
|
|
||||||
git(repo, &["add", ".relicario"], &[]);
|
|
||||||
git(repo, &["commit", "-q", "-m", "device files"], &[]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn registered_non_revoked_key_accepted() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let repo = tmp.path();
|
|
||||||
init_repo(repo);
|
|
||||||
|
|
||||||
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
|
|
||||||
write_device_files(
|
|
||||||
repo,
|
|
||||||
&[DeviceEntry {
|
|
||||||
name: "alice".into(),
|
|
||||||
public_key: pub_a.clone(),
|
|
||||||
added_at: 1_700_000_000,
|
|
||||||
added_by: "bootstrap".into(),
|
|
||||||
}],
|
|
||||||
&[],
|
|
||||||
);
|
|
||||||
|
|
||||||
let allowed = repo.join("test_allowed_signers");
|
|
||||||
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
|
|
||||||
|
|
||||||
let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "x", "a.txt", "hi");
|
|
||||||
|
|
||||||
AssertCommand::cargo_bin("relicario-server")
|
|
||||||
.unwrap()
|
|
||||||
.current_dir(repo)
|
|
||||||
.args(["verify-commit", &sha])
|
|
||||||
.assert()
|
|
||||||
.success();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unregistered_key_rejected() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let repo = tmp.path();
|
|
||||||
init_repo(repo);
|
|
||||||
|
|
||||||
let (_, _, pub_a) = write_keypair(repo, "alice");
|
|
||||||
let (priv_evil, _, pub_evil) = write_keypair(repo, "evil");
|
|
||||||
|
|
||||||
// Only Alice is registered.
|
|
||||||
write_device_files(
|
|
||||||
repo,
|
|
||||||
&[DeviceEntry {
|
|
||||||
name: "alice".into(),
|
|
||||||
public_key: pub_a.clone(),
|
|
||||||
added_at: 1_700_000_000,
|
|
||||||
added_by: "bootstrap".into(),
|
|
||||||
}],
|
|
||||||
&[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Evil signs against a file containing both keys so git commit signing works,
|
|
||||||
// but the binary's allowed-signers (from devices.json) only has Alice.
|
|
||||||
let allowed = repo.join("test_allowed_signers");
|
|
||||||
fs::write(
|
|
||||||
&allowed,
|
|
||||||
format!("relicario {}\nrelicario {}\n", pub_a.trim(), pub_evil.trim()),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let sha = sign_commit(repo, &priv_evil, &allowed, 1_710_000_000, "evil", "a.txt", "hi");
|
|
||||||
|
|
||||||
AssertCommand::cargo_bin("relicario-server")
|
|
||||||
.unwrap()
|
|
||||||
.current_dir(repo)
|
|
||||||
.args(["verify-commit", &sha])
|
|
||||||
.assert()
|
|
||||||
.failure()
|
|
||||||
.stderr(predicate::str::contains("unregistered"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn revoked_key_after_revoked_at_rejected() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let repo = tmp.path();
|
|
||||||
init_repo(repo);
|
|
||||||
|
|
||||||
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
|
|
||||||
|
|
||||||
// Alice's entry is only in revoked.json (was removed from devices.json after revocation).
|
|
||||||
write_device_files(
|
|
||||||
repo,
|
|
||||||
&[],
|
|
||||||
&[RevokedEntry {
|
|
||||||
name: "alice".into(),
|
|
||||||
public_key: pub_a.clone(),
|
|
||||||
revoked_at: 1_705_000_000,
|
|
||||||
revoked_by: "admin".into(),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
|
|
||||||
let allowed = repo.join("test_allowed_signers");
|
|
||||||
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
|
|
||||||
|
|
||||||
// Commit dated AFTER revocation.
|
|
||||||
let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "post", "a.txt", "hi");
|
|
||||||
|
|
||||||
AssertCommand::cargo_bin("relicario-server")
|
|
||||||
.unwrap()
|
|
||||||
.current_dir(repo)
|
|
||||||
.args(["verify-commit", &sha])
|
|
||||||
.assert()
|
|
||||||
.failure()
|
|
||||||
.stderr(predicate::str::contains("revoked"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn revoked_key_before_revoked_at_accepted_historical() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let repo = tmp.path();
|
|
||||||
init_repo(repo);
|
|
||||||
|
|
||||||
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
|
|
||||||
|
|
||||||
// Same as above: Alice only in revoked.json.
|
|
||||||
write_device_files(
|
|
||||||
repo,
|
|
||||||
&[],
|
|
||||||
&[RevokedEntry {
|
|
||||||
name: "alice".into(),
|
|
||||||
public_key: pub_a.clone(),
|
|
||||||
revoked_at: 1_705_000_000,
|
|
||||||
revoked_by: "admin".into(),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
|
|
||||||
let allowed = repo.join("test_allowed_signers");
|
|
||||||
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
|
|
||||||
|
|
||||||
// Commit dated BEFORE revocation -- historical case must pass.
|
|
||||||
let sha = sign_commit(repo, &priv_a, &allowed, 1_700_000_000, "historical", "a.txt", "hi");
|
|
||||||
|
|
||||||
AssertCommand::cargo_bin("relicario-server")
|
|
||||||
.unwrap()
|
|
||||||
.current_dir(repo)
|
|
||||||
.args(["verify-commit", &sha])
|
|
||||||
.assert()
|
|
||||||
.success();
|
|
||||||
}
|
|
||||||
@@ -59,34 +59,3 @@ Without device authentication, access control is transport-layer only:
|
|||||||
|
|
||||||
Device registration was optional before v0.4.0. With device auth enabled,
|
Device registration was optional before v0.4.0. With device auth enabled,
|
||||||
all commits must be signed by a registered device.
|
all commits must be signed by a registered device.
|
||||||
|
|
||||||
## Configuration env vars
|
|
||||||
|
|
||||||
Relicario reads the following environment variables. Each is a trust
|
|
||||||
boundary: an attacker who can set them in the user's environment can
|
|
||||||
influence Relicario's behavior. They are listed here for security
|
|
||||||
reviewers to audit the surface in one place.
|
|
||||||
|
|
||||||
### User-facing (active in all builds)
|
|
||||||
|
|
||||||
| Variable | Purpose | Trust |
|
|
||||||
|---|---|---|
|
|
||||||
| `RELICARIO_IMAGE` | Override the reference-image JPEG path used during vault unlock. | Trusted: filesystem path under the user's control. Read-only; its bytes feed `imgsecret::extract_secret`. |
|
|
||||||
| `RELICARIO_GITEA_URL` | Gitea API base URL for `relicario device add`. Equivalent to `--gitea-url`. | Trusted: HTTPS URL. Used only in the device-add code path. |
|
|
||||||
| `RELICARIO_GITEA_TOKEN` | Gitea personal-access token. Equivalent to `--gitea-token`. | **Secret**: anyone who can read this env var can manage the user's deploy keys via the Gitea API. The CLI never logs it. |
|
|
||||||
| `RELICARIO_GITEA_OWNER` | Gitea repository owner (e.g. `alee`). Equivalent to `--owner`. | Trusted: opaque string. |
|
|
||||||
| `RELICARIO_GITEA_REPO` | Gitea repository name (e.g. `vault`). Equivalent to `--repo`. | Trusted: opaque string. |
|
|
||||||
|
|
||||||
### Debug-only (compiled out of `cargo build --release`)
|
|
||||||
|
|
||||||
The following variables are gated behind `cfg(debug_assertions)` and
|
|
||||||
are **no-ops** in release builds. The env-var lookup is removed by the
|
|
||||||
optimiser from any binary built without debug assertions (i.e. the
|
|
||||||
standard `--release` profile).
|
|
||||||
|
|
||||||
| Variable | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `RELICARIO_NO_GROUPS_CACHE` | Suppress the plaintext `groups.cache` write. Developer debugging tool for the cache logic. |
|
|
||||||
| `RELICARIO_TEST_PASSPHRASE` | Bypass the `rpassword` prompt during integration tests. |
|
|
||||||
| `RELICARIO_TEST_ITEM_SECRET` | Bypass the `rpassword` prompt for item-secret fields during integration tests. |
|
|
||||||
| `RELICARIO_TEST_BACKUP_PASSPHRASE` | Bypass the `rpassword` prompt for backup export/restore passphrases during integration tests. |
|
|
||||||
|
|||||||
13
extension/src/__stubs__/relicario_wasm.stub.ts
Normal file
13
extension/src/__stubs__/relicario_wasm.stub.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// Stub for the runtime-only WASM module. Used by vitest so that modules
|
||||||
|
// importing relicario_wasm.js can be loaded in a Node/happy-dom environment.
|
||||||
|
// Individual tests that exercise WASM calls should mock the relevant exports.
|
||||||
|
|
||||||
|
export default async function init(): Promise<void> {}
|
||||||
|
export const unlock = (): never => { throw new Error('wasm stub: unlock not mocked'); };
|
||||||
|
export const lock = (): void => {};
|
||||||
|
export const manifest_encrypt = (): never => { throw new Error('wasm stub: manifest_encrypt not mocked'); };
|
||||||
|
export const manifest_decrypt = (): never => { throw new Error('wasm stub: manifest_decrypt not mocked'); };
|
||||||
|
export const settings_encrypt = (): never => { throw new Error('wasm stub: settings_encrypt not mocked'); };
|
||||||
|
export const default_vault_settings_json = (): string => '{}';
|
||||||
|
export const embed_image_secret = (): never => { throw new Error('wasm stub: embed_image_secret not mocked'); };
|
||||||
|
export const register_device = (): never => { throw new Error('wasm stub: register_device not mocked'); };
|
||||||
@@ -12,6 +12,22 @@ vi.mock('../../../shared/state', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { sendMessage } from '../../../shared/state';
|
import { sendMessage } from '../../../shared/state';
|
||||||
|
import { DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR } from '../../../shared/color-scheme';
|
||||||
|
|
||||||
|
function mockChromeStorage(initial: Record<string, unknown> = {}) {
|
||||||
|
const store: Record<string, unknown> = { ...initial };
|
||||||
|
(global as any).chrome = {
|
||||||
|
storage: {
|
||||||
|
sync: {
|
||||||
|
get: vi.fn((key: string) => Promise.resolve(
|
||||||
|
key in store ? { [key]: store[key] } : {})),
|
||||||
|
set: vi.fn((kv: Record<string, unknown>) => { Object.assign(store, kv); return Promise.resolve(); }),
|
||||||
|
remove: vi.fn((key: string) => { delete store[key]; return Promise.resolve(); }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
function settingsResponses() {
|
function settingsResponses() {
|
||||||
// Two parallel calls in renderSettings: get_settings + get_blacklist.
|
// Two parallel calls in renderSettings: get_settings + get_blacklist.
|
||||||
@@ -30,6 +46,7 @@ describe('settings view', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders a Sync now button', async () => {
|
it('renders a Sync now button', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
settingsResponses();
|
settingsResponses();
|
||||||
|
|
||||||
await renderSettings(app);
|
await renderSettings(app);
|
||||||
@@ -38,6 +55,7 @@ describe('settings view', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('clicking Sync now sends a sync message and shows feedback on success', async () => {
|
it('clicking Sync now sends a sync message and shows feedback on success', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
settingsResponses();
|
settingsResponses();
|
||||||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||||
|
|
||||||
@@ -52,6 +70,7 @@ describe('settings view', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows the error when sync fails', async () => {
|
it('shows the error when sync fails', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
settingsResponses();
|
settingsResponses();
|
||||||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: false, error: 'remote_unreachable' });
|
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: false, error: 'remote_unreachable' });
|
||||||
|
|
||||||
@@ -64,3 +83,109 @@ describe('settings view', () => {
|
|||||||
expect(status.textContent).toMatch(/remote_unreachable/);
|
expect(status.textContent).toMatch(/remote_unreachable/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('settings Display section', () => {
|
||||||
|
let app: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '<div id="app"></div>';
|
||||||
|
app = document.getElementById('app')!;
|
||||||
|
(sendMessage as ReturnType<typeof vi.fn>).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders digit and symbol color pickers with default values when storage is empty', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
settingsResponses();
|
||||||
|
|
||||||
|
await renderSettings(app);
|
||||||
|
|
||||||
|
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color');
|
||||||
|
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color');
|
||||||
|
expect(digitInput).not.toBeNull();
|
||||||
|
expect(symbolInput).not.toBeNull();
|
||||||
|
expect(digitInput!.value).toBe(DEFAULT_DIGIT_COLOR);
|
||||||
|
expect(symbolInput!.value).toBe(DEFAULT_SYMBOL_COLOR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders pickers with stored values when storage has a scheme', async () => {
|
||||||
|
mockChromeStorage({
|
||||||
|
password_display_scheme: { digit_color: '#112233', symbol_color: '#aabbcc' },
|
||||||
|
});
|
||||||
|
settingsResponses();
|
||||||
|
|
||||||
|
await renderSettings(app);
|
||||||
|
|
||||||
|
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color');
|
||||||
|
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color');
|
||||||
|
expect(digitInput!.value).toBe('#112233');
|
||||||
|
expect(symbolInput!.value).toBe('#aabbcc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a color-preview-swatch element', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
settingsResponses();
|
||||||
|
|
||||||
|
await renderSettings(app);
|
||||||
|
|
||||||
|
expect(app.querySelector('#display-swatch')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changing digit color calls saveColorScheme with updated scheme', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
settingsResponses();
|
||||||
|
|
||||||
|
await renderSettings(app);
|
||||||
|
|
||||||
|
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color')!;
|
||||||
|
digitInput.value = '#ff0000';
|
||||||
|
digitInput.dispatchEvent(new Event('change'));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
const syncSet = (global as any).chrome.storage.sync.set as ReturnType<typeof vi.fn>;
|
||||||
|
expect(syncSet).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
password_display_scheme: expect.objectContaining({ digit_color: '#ff0000' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changing symbol color calls saveColorScheme with updated scheme', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
settingsResponses();
|
||||||
|
|
||||||
|
await renderSettings(app);
|
||||||
|
|
||||||
|
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color')!;
|
||||||
|
symbolInput.value = '#00ff00';
|
||||||
|
symbolInput.dispatchEvent(new Event('change'));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
const syncSet = (global as any).chrome.storage.sync.set as ReturnType<typeof vi.fn>;
|
||||||
|
expect(syncSet).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
password_display_scheme: expect.objectContaining({ symbol_color: '#00ff00' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking reset calls chrome.storage.sync.remove and restores defaults', async () => {
|
||||||
|
mockChromeStorage({
|
||||||
|
password_display_scheme: { digit_color: '#112233', symbol_color: '#aabbcc' },
|
||||||
|
});
|
||||||
|
settingsResponses();
|
||||||
|
|
||||||
|
await renderSettings(app);
|
||||||
|
|
||||||
|
const resetBtn = app.querySelector<HTMLButtonElement>('#display-reset')!;
|
||||||
|
resetBtn.click();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
const syncRemove = (global as any).chrome.storage.sync.remove as ReturnType<typeof vi.fn>;
|
||||||
|
expect(syncRemove).toHaveBeenCalledWith('password_display_scheme');
|
||||||
|
|
||||||
|
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color')!;
|
||||||
|
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color')!;
|
||||||
|
expect(digitInput.value).toBe(DEFAULT_DIGIT_COLOR);
|
||||||
|
expect(symbolInput.value).toBe(DEFAULT_SYMBOL_COLOR);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/// Field history view — shows password/concealed field history for an item.
|
/// Field history view — shows password/concealed field history for an item.
|
||||||
|
|
||||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||||
|
import { colorizePassword } from '../../shared/password-coloring';
|
||||||
import type { FieldHistoryView } from '../../shared/types';
|
import type { FieldHistoryView } from '../../shared/types';
|
||||||
|
|
||||||
function relativeTime(unixSec: number): string {
|
function relativeTime(unixSec: number): string {
|
||||||
@@ -103,6 +104,16 @@ export async function renderFieldHistory(app: HTMLElement): Promise<void> {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Colorize revealed entries: replace plain-text content with colorized spans
|
||||||
|
app.querySelectorAll<HTMLElement>('.history-entry__value.revealed').forEach((el) => {
|
||||||
|
const key = el.closest<HTMLElement>('.history-entry')?.dataset.entry ?? '';
|
||||||
|
const plaintext = valueStore.get(key);
|
||||||
|
if (plaintext !== undefined) {
|
||||||
|
el.textContent = '';
|
||||||
|
el.appendChild(colorizePassword(plaintext));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Wire handlers
|
// Wire handlers
|
||||||
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('detail'));
|
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('detail'));
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
/// copy click handlers on any rendered rows.
|
/// copy click handlers on any rendered rows.
|
||||||
|
|
||||||
import { escapeHtml } from '../../shared/state';
|
import { escapeHtml } from '../../shared/state';
|
||||||
|
import { colorizePassword } from '../../shared/password-coloring';
|
||||||
import type { Item, Section, Field, FieldValue } from '../../shared/types';
|
import type { Item, Section, Field, FieldValue } from '../../shared/types';
|
||||||
|
|
||||||
export interface RowOpts {
|
export interface RowOpts {
|
||||||
@@ -46,6 +47,7 @@ export interface ConcealedRowOpts {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
kind?: 'password' | 'concealed';
|
||||||
monospace?: boolean;
|
monospace?: boolean;
|
||||||
multiline?: boolean;
|
multiline?: boolean;
|
||||||
}
|
}
|
||||||
@@ -53,12 +55,15 @@ export interface ConcealedRowOpts {
|
|||||||
/// Concealed row — value rendered hidden until the user clicks "show".
|
/// Concealed row — value rendered hidden until the user clicks "show".
|
||||||
/// Plaintext is stored in `data-field-value` on the row element and copied
|
/// Plaintext is stored in `data-field-value` on the row element and copied
|
||||||
/// to the visible value span on reveal. Copy button always copies plaintext.
|
/// to the visible value span on reveal. Copy button always copies plaintext.
|
||||||
|
/// When `kind` is "password", wireFieldHandlers applies colorizePassword on
|
||||||
|
/// reveal so digits/symbols/letters are rendered in distinct colours.
|
||||||
export function renderConcealedRow(opts: ConcealedRowOpts): string {
|
export function renderConcealedRow(opts: ConcealedRowOpts): string {
|
||||||
const { id, label, value, monospace, multiline } = opts;
|
const { id, label, value, kind, monospace, multiline } = opts;
|
||||||
const placeholder = multiline ? `•••• (${value.length} chars)` : '••••';
|
const placeholder = multiline ? `•••• (${value.length} chars)` : '••••';
|
||||||
const valueClass = `field-row__value${monospace ? ' monospace' : ''}`;
|
const valueClass = `field-row__value${monospace ? ' monospace' : ''}`;
|
||||||
|
const kindAttr = kind ? ` data-field-kind="${escapeHtml(kind)}"` : '';
|
||||||
return `
|
return `
|
||||||
<div class="field-row" data-field-id="${escapeHtml(id)}" data-revealed="false" data-field-value="${escapeHtml(value)}" data-field-multiline="${multiline ? 'true' : 'false'}">
|
<div class="field-row" data-field-id="${escapeHtml(id)}" data-revealed="false" data-field-value="${escapeHtml(value)}" data-field-multiline="${multiline ? 'true' : 'false'}"${kindAttr}>
|
||||||
<span class="field-row__label">${escapeHtml(label)}</span>
|
<span class="field-row__label">${escapeHtml(label)}</span>
|
||||||
<span class="${valueClass}" data-field-role="value">${escapeHtml(placeholder)}</span>
|
<span class="${valueClass}" data-field-role="value">${escapeHtml(placeholder)}</span>
|
||||||
<span class="field-row__actions">
|
<span class="field-row__actions">
|
||||||
@@ -100,8 +105,14 @@ export function wireFieldHandlers(scope: HTMLElement): void {
|
|||||||
valueEl.textContent = placeholder;
|
valueEl.textContent = placeholder;
|
||||||
row.setAttribute('data-revealed', 'false');
|
row.setAttribute('data-revealed', 'false');
|
||||||
btn.textContent = 'show';
|
btn.textContent = 'show';
|
||||||
|
} else {
|
||||||
|
const isPassword = row.getAttribute('data-field-kind') === 'password';
|
||||||
|
valueEl.textContent = '';
|
||||||
|
if (isPassword) {
|
||||||
|
valueEl.appendChild(colorizePassword(plaintext));
|
||||||
} else {
|
} else {
|
||||||
valueEl.textContent = plaintext;
|
valueEl.textContent = plaintext;
|
||||||
|
}
|
||||||
row.setAttribute('data-revealed', 'true');
|
row.setAttribute('data-revealed', 'true');
|
||||||
btn.textContent = 'hide';
|
btn.textContent = 'hide';
|
||||||
}
|
}
|
||||||
@@ -150,6 +161,7 @@ export function renderSections(item: Item, idPrefix: string): string {
|
|||||||
id: `${idPrefix}-s${sIdx}-f${fIdx}`,
|
id: `${idPrefix}-s${sIdx}-f${fIdx}`,
|
||||||
label: field.label,
|
label: field.label,
|
||||||
value: field.value.value,
|
value: field.value.value,
|
||||||
|
kind: field.value.kind,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { sendMessage } from '../../shared/state';
|
import { sendMessage } from '../../shared/state';
|
||||||
import type { GeneratorRequest, VaultSettings } from '../../shared/types';
|
import type { GeneratorRequest, VaultSettings } from '../../shared/types';
|
||||||
|
import { colorizePassword } from '../../shared/password-coloring';
|
||||||
|
|
||||||
interface UiKnobs {
|
interface UiKnobs {
|
||||||
kind: 'random' | 'bip39';
|
kind: 'random' | 'bip39';
|
||||||
@@ -138,7 +139,10 @@ export function openGeneratorPanel(opts: OpenPanelOpts): void {
|
|||||||
const d = resp.data as { password?: string; passphrase?: string };
|
const d = resp.data as { password?: string; passphrase?: string };
|
||||||
currentPreview = d.password ?? d.passphrase ?? '';
|
currentPreview = d.password ?? d.passphrase ?? '';
|
||||||
const el = host.querySelector('.preview__value');
|
const el = host.querySelector('.preview__value');
|
||||||
if (el) el.textContent = currentPreview;
|
if (el) {
|
||||||
|
el.textContent = '';
|
||||||
|
el.appendChild(colorizePassword(currentPreview));
|
||||||
|
}
|
||||||
updateValidation();
|
updateValidation();
|
||||||
}
|
}
|
||||||
}, 150);
|
}, 150);
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
import { sendMessage, navigate, escapeHtml } from '../../shared/state';
|
import { sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||||
import type { DeviceSettings } from '../../shared/types';
|
import type { DeviceSettings } from '../../shared/types';
|
||||||
import { GLYPH_TRASH, GLYPH_DEVICES } from '../../shared/glyphs';
|
import { GLYPH_TRASH, GLYPH_DEVICES } from '../../shared/glyphs';
|
||||||
|
import {
|
||||||
|
loadColorScheme, saveColorScheme, resetColorScheme,
|
||||||
|
DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR,
|
||||||
|
} from '../../shared/color-scheme';
|
||||||
|
import { colorizePassword } from '../../shared/password-coloring';
|
||||||
|
|
||||||
export async function renderSettings(app: HTMLElement): Promise<void> {
|
export async function renderSettings(app: HTMLElement): Promise<void> {
|
||||||
app.innerHTML = '<div class="pad" style="text-align:center; padding-top:20px;"><span class="spinner"></span></div>';
|
app.innerHTML = '<div class="pad" style="text-align:center; padding-top:20px;"><span class="spinner"></span></div>';
|
||||||
@@ -62,6 +67,9 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
|
|||||||
<div id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div>
|
<div id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:16px;" id="display-section-container">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">blacklisted sites</div>
|
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">blacklisted sites</div>
|
||||||
<div id="blacklist-container">
|
<div id="blacklist-container">
|
||||||
@@ -119,4 +127,65 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Render Display section after the rest of the DOM is ready
|
||||||
|
await renderDisplaySection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSwatch(swatch: HTMLElement, digitColor: string, symbolColor: string): void {
|
||||||
|
swatch.style.setProperty('--relicario-pwd-digit-color', digitColor);
|
||||||
|
swatch.style.setProperty('--relicario-pwd-symbol-color', symbolColor);
|
||||||
|
swatch.innerHTML = '';
|
||||||
|
swatch.appendChild(colorizePassword('Abc123!@#xyz'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDisplaySection(): Promise<void> {
|
||||||
|
// The Display section container must be present in the DOM before we call this
|
||||||
|
const container = document.getElementById('display-section-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">display</div>
|
||||||
|
<div style="margin-bottom:8px;">
|
||||||
|
<label style="display:flex; align-items:center; gap:8px; font-size:13px;">
|
||||||
|
<input type="color" id="display-digit-color" value="${escapeHtml(scheme.digit_color)}">
|
||||||
|
digit color
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:8px;">
|
||||||
|
<label style="display:flex; align-items:center; gap:8px; font-size:13px;">
|
||||||
|
<input type="color" id="display-symbol-color" value="${escapeHtml(scheme.symbol_color)}">
|
||||||
|
symbol color
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="display-swatch" class="color-preview-swatch"></div>
|
||||||
|
<div style="margin-top:8px;">
|
||||||
|
<button id="display-reset" class="btn" style="font-size:11px;">reset to defaults</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const digitInput = document.getElementById('display-digit-color') as HTMLInputElement;
|
||||||
|
const symbolInput = document.getElementById('display-symbol-color') as HTMLInputElement;
|
||||||
|
const swatch = document.getElementById('display-swatch') as HTMLElement;
|
||||||
|
|
||||||
|
// Render initial swatch
|
||||||
|
updateSwatch(swatch, scheme.digit_color, scheme.symbol_color);
|
||||||
|
|
||||||
|
async function onColorChange(): Promise<void> {
|
||||||
|
const newScheme = { digit_color: digitInput.value, symbol_color: symbolInput.value };
|
||||||
|
await saveColorScheme(newScheme);
|
||||||
|
updateSwatch(swatch, newScheme.digit_color, newScheme.symbol_color);
|
||||||
|
}
|
||||||
|
|
||||||
|
digitInput.addEventListener('change', () => void onColorChange());
|
||||||
|
symbolInput.addEventListener('change', () => void onColorChange());
|
||||||
|
|
||||||
|
document.getElementById('display-reset')?.addEventListener('click', async () => {
|
||||||
|
await resetColorScheme();
|
||||||
|
digitInput.value = DEFAULT_DIGIT_COLOR;
|
||||||
|
symbolInput.value = DEFAULT_SYMBOL_COLOR;
|
||||||
|
updateSwatch(swatch, DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ vi.mock('../../../../setup/setup-helpers', () => ({
|
|||||||
entropyText: vi.fn(() => ''),
|
entropyText: vi.fn(() => ''),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { renderForm } from '../login';
|
import { renderForm, applyGeneratedPassword } from '../login';
|
||||||
import { sendMessage } from '../../../../shared/state';
|
import { sendMessage } from '../../../../shared/state';
|
||||||
|
|
||||||
describe('login form smart inputs', () => {
|
describe('login form smart inputs', () => {
|
||||||
@@ -154,3 +154,37 @@ describe('Login save shape', () => {
|
|||||||
expect(addCall).toBeUndefined();
|
expect(addCall).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('regenerate handler dispatches input event', () => {
|
||||||
|
it('dispatches an InputEvent on the input after value is set', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'password';
|
||||||
|
document.body.appendChild(input);
|
||||||
|
|
||||||
|
const dispatchSpy = vi.spyOn(input, 'dispatchEvent');
|
||||||
|
|
||||||
|
applyGeneratedPassword(input, 'sCMtTJkF%GN^mF#-N6D%');
|
||||||
|
|
||||||
|
expect(input.value).toBe('sCMtTJkF%GN^mF#-N6D%');
|
||||||
|
expect(input.type).toBe('text');
|
||||||
|
expect(dispatchSpy).toHaveBeenCalled();
|
||||||
|
const evt = dispatchSpy.mock.calls.find(c => c[0] instanceof InputEvent)?.[0] as InputEvent;
|
||||||
|
expect(evt).toBeDefined();
|
||||||
|
expect(evt.type).toBe('input');
|
||||||
|
expect(evt.bubbles).toBe(true);
|
||||||
|
|
||||||
|
document.body.removeChild(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bubbling listener fires when applyGeneratedPassword is called', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
document.body.appendChild(input);
|
||||||
|
|
||||||
|
let listenerFired = false;
|
||||||
|
input.addEventListener('input', () => { listenerFired = true; });
|
||||||
|
applyGeneratedPassword(input, 'newpass');
|
||||||
|
expect(listenerFired).toBe(true);
|
||||||
|
|
||||||
|
document.body.removeChild(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ import { wireTotpPreview, wireTotpQr } from '../../../shared/form-affordances/to
|
|||||||
import { wireNotesMonoToggle } from '../../../shared/form-affordances/notes-tools';
|
import { wireNotesMonoToggle } from '../../../shared/form-affordances/notes-tools';
|
||||||
import { scheduleRate } from '../../../setup/setup-helpers';
|
import { scheduleRate } from '../../../setup/setup-helpers';
|
||||||
|
|
||||||
|
/// Sets a generated password on an input, reveals it as plain text, then
|
||||||
|
/// dispatches a synthetic InputEvent so listeners (e.g. the strength meter)
|
||||||
|
/// re-evaluate the new value.
|
||||||
|
export function applyGeneratedPassword(input: HTMLInputElement, value: string): void {
|
||||||
|
input.value = value;
|
||||||
|
input.type = 'text';
|
||||||
|
input.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
/// Called by the dispatcher before each render. Stops any in-flight
|
/// Called by the dispatcher before each render. Stops any in-flight
|
||||||
/// tickers / intervals / listeners the previous view may have attached.
|
/// tickers / intervals / listeners the previous view may have attached.
|
||||||
export function teardown(): void {
|
export function teardown(): void {
|
||||||
@@ -75,7 +84,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
|||||||
${renderSignatureBlock({ accent: 'gold', children: sigInner })}
|
${renderSignatureBlock({ accent: 'gold', children: sigInner })}
|
||||||
</div>
|
</div>
|
||||||
${username ? renderRow({ label: 'username', value: username, copyable: true }) : ''}
|
${username ? renderRow({ label: 'username', value: username, copyable: true }) : ''}
|
||||||
${renderConcealedRow({ id: 'login-password', label: 'password', value: password })}
|
${renderConcealedRow({ id: 'login-password', label: 'password', value: password, kind: 'password' })}
|
||||||
${url ? renderRow({ label: 'url', value: url, href: url }) : ''}
|
${url ? renderRow({ label: 'url', value: url, href: url }) : ''}
|
||||||
${hasTotp ? `
|
${hasTotp ? `
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
@@ -348,6 +357,7 @@ export function renderForm(
|
|||||||
|
|
||||||
${sectionsHtml}
|
${sectionsHtml}
|
||||||
|
|
||||||
|
<div class="${surface === 'fullscreen' ? 'form-lower' : ''}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="notes-with-toggle">
|
<div class="notes-with-toggle">
|
||||||
<label class="label" for="f-notes" style="margin:0;flex:1;">notes</label>
|
<label class="label" for="f-notes" style="margin:0;flex:1;">notes</label>
|
||||||
@@ -363,6 +373,7 @@ export function renderForm(
|
|||||||
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const rerender = (): void => {
|
const rerender = (): void => {
|
||||||
@@ -433,7 +444,7 @@ export function renderForm(
|
|||||||
context: 'fill-field',
|
context: 'fill-field',
|
||||||
onPicked: (value) => {
|
onPicked: (value) => {
|
||||||
const pw = document.getElementById('f-password') as HTMLInputElement | null;
|
const pw = document.getElementById('f-password') as HTMLInputElement | null;
|
||||||
if (pw) { pw.value = value; pw.type = 'text'; }
|
if (pw) applyGeneratedPassword(pw, value);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
/// Navigation works by updating `currentState` and calling `render()`.
|
/// Navigation works by updating `currentState` and calling `render()`.
|
||||||
|
|
||||||
import type { Request, Response } from '../shared/messages';
|
import type { Request, Response } from '../shared/messages';
|
||||||
|
import { lookupErrorCopy } from '../shared/error-copy';
|
||||||
import type { ItemId, ManifestEntry, Item } from '../shared/types';
|
import type { ItemId, ManifestEntry, Item } from '../shared/types';
|
||||||
import { registerHost } from '../shared/state';
|
import { registerHost } from '../shared/state';
|
||||||
import { renderUnlock } from './components/unlock';
|
import { renderUnlock } from './components/unlock';
|
||||||
@@ -18,6 +19,7 @@ import { renderFieldHistory } from './components/field-history';
|
|||||||
import { teardown as teardownTrash } from './components/trash';
|
import { teardown as teardownTrash } from './components/trash';
|
||||||
import { teardown as teardownDevices } from './components/devices';
|
import { teardown as teardownDevices } from './components/devices';
|
||||||
import { teardown as teardownFieldHistory } from './components/field-history';
|
import { teardown as teardownFieldHistory } from './components/field-history';
|
||||||
|
import { applyColorScheme } from '../shared/color-scheme';
|
||||||
|
|
||||||
// --- Escape HTML to prevent XSS ---
|
// --- Escape HTML to prevent XSS ---
|
||||||
export function escapeHtml(str: string): string {
|
export function escapeHtml(str: string): string {
|
||||||
@@ -144,19 +146,8 @@ export function humanizeError(err: string): string {
|
|||||||
if (/settings json:/i.test(err)) {
|
if (/settings json:/i.test(err)) {
|
||||||
return 'Settings are in an invalid format — try reloading the extension.';
|
return 'Settings are in an invalid format — try reloading the extension.';
|
||||||
}
|
}
|
||||||
if (/vault_locked/i.test(err)) {
|
const copy = lookupErrorCopy(err);
|
||||||
return 'Vault is locked. Unlock and try again.';
|
return copy.body;
|
||||||
}
|
|
||||||
if (/origin_mismatch/i.test(err)) {
|
|
||||||
return 'This login belongs to a different site — refusing to leak credentials cross-origin.';
|
|
||||||
}
|
|
||||||
if (/unauthorized_sender/i.test(err)) {
|
|
||||||
return 'This action is not allowed from here.';
|
|
||||||
}
|
|
||||||
if (/tab_navigated|captured_tab_gone/i.test(err)) {
|
|
||||||
return 'The browser tab changed before the fill could complete — try again.';
|
|
||||||
}
|
|
||||||
return err;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Navigation ---
|
// --- Navigation ---
|
||||||
@@ -225,6 +216,14 @@ function render(): void {
|
|||||||
// --- Init ---
|
// --- Init ---
|
||||||
|
|
||||||
async function init(): Promise<void> {
|
async function init(): Promise<void> {
|
||||||
|
await applyColorScheme();
|
||||||
|
|
||||||
|
chrome.storage.onChanged.addListener((changes, area) => {
|
||||||
|
if (area === 'sync' && 'password_display_scheme' in changes) {
|
||||||
|
void applyColorScheme();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Snapshot the active tab at popup-open — the fill path uses this
|
// Snapshot the active tab at popup-open — the fill path uses this
|
||||||
// tabId/url pair so the SW can verify the tab hasn't navigated before
|
// tabId/url pair so the SW can verify the tab hasn't navigated before
|
||||||
// forwarding credentials (audit M5 + TOCTOU close via expectedHost).
|
// forwarding credentials (audit M5 + TOCTOU close via expectedHost).
|
||||||
|
|||||||
@@ -38,6 +38,10 @@
|
|||||||
|
|
||||||
/* Focus */
|
/* Focus */
|
||||||
--focus-ring: 0 0 0 2px var(--gold-ring);
|
--focus-ring: 0 0 0 2px var(--gold-ring);
|
||||||
|
|
||||||
|
/* Password coloring (P1) */
|
||||||
|
--relicario-pwd-digit-color: #2563eb;
|
||||||
|
--relicario-pwd-symbol-color: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -1554,3 +1558,18 @@ textarea {
|
|||||||
.logo-lockup .brand-logo { width: 42px; height: 42px; margin: 0 auto 10px; }
|
.logo-lockup .brand-logo { width: 42px; height: 42px; margin: 0 auto 10px; }
|
||||||
.logo-lockup .brand { font-size: 17px; font-weight: 600; color: var(--gold-text); letter-spacing: 0.5px; }
|
.logo-lockup .brand { font-size: 17px; font-weight: 600; color: var(--gold-text); letter-spacing: 0.5px; }
|
||||||
.tagline { color: var(--text-dim); font-size: 11px; margin-top: 4px; letter-spacing: 0.3px; }
|
.tagline { color: var(--text-dim); font-size: 11px; margin-top: 4px; letter-spacing: 0.3px; }
|
||||||
|
|
||||||
|
/* Password character-class coloring */
|
||||||
|
.pwd-digit { color: var(--relicario-pwd-digit-color); }
|
||||||
|
.pwd-symbol { color: var(--relicario-pwd-symbol-color); }
|
||||||
|
.pwd-letter { color: inherit; }
|
||||||
|
|
||||||
|
.color-preview-swatch {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-mid);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
}
|
||||||
|
|||||||
37
extension/src/setup/__tests__/setup.test.ts
Normal file
37
extension/src/setup/__tests__/setup.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { finishSetup } from '../setup';
|
||||||
|
|
||||||
|
describe('finishSetup', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(global as any).chrome = {
|
||||||
|
tabs: {
|
||||||
|
create: vi.fn(() => Promise.resolve({ id: 999 })),
|
||||||
|
getCurrent: vi.fn(() => Promise.resolve({ id: 42 })),
|
||||||
|
remove: vi.fn(() => Promise.resolve()),
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
getURL: vi.fn((p: string) => `chrome-extension://abc/${p}`),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens vault.html in a new tab', async () => {
|
||||||
|
await finishSetup();
|
||||||
|
expect(chrome.runtime.getURL).toHaveBeenCalledWith('vault.html');
|
||||||
|
expect(chrome.tabs.create).toHaveBeenCalledWith({
|
||||||
|
url: 'chrome-extension://abc/vault.html',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes the current setup tab after opening the vault tab', async () => {
|
||||||
|
await finishSetup();
|
||||||
|
expect(chrome.tabs.getCurrent).toHaveBeenCalled();
|
||||||
|
expect(chrome.tabs.remove).toHaveBeenCalledWith(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still opens the vault tab even if closing the setup tab fails', async () => {
|
||||||
|
(chrome.tabs.remove as any).mockRejectedValueOnce(new Error('no permission'));
|
||||||
|
await expect(finishSetup()).resolves.not.toThrow();
|
||||||
|
expect(chrome.tabs.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1101,6 +1101,7 @@ function attachStep5(): void {
|
|||||||
|
|
||||||
state.configPushed = true;
|
state.configPushed = true;
|
||||||
render();
|
render();
|
||||||
|
void finishSetup();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('[relicario setup] register device failed:', err);
|
console.error('[relicario setup] register device failed:', err);
|
||||||
state.error = `Failed to register device: ${err instanceof Error ? err.message : String(err)}`;
|
state.error = `Failed to register device: ${err instanceof Error ? err.message : String(err)}`;
|
||||||
@@ -1131,6 +1132,23 @@ function attachStep5(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Completion handoff ---
|
||||||
|
|
||||||
|
/// Open the fullscreen vault tab and best-effort close the setup tab.
|
||||||
|
export async function finishSetup(): Promise<void> {
|
||||||
|
const vaultUrl = chrome.runtime.getURL('vault.html');
|
||||||
|
await chrome.tabs.create({ url: vaultUrl });
|
||||||
|
try {
|
||||||
|
const current = await chrome.tabs.getCurrent();
|
||||||
|
if (current?.id !== undefined) {
|
||||||
|
await chrome.tabs.remove(current.id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Setup tab may not be closeable (e.g., opened as popup rather than a tab).
|
||||||
|
// The vault tab is open — that's the user-visible success.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Boot ---
|
// --- Boot ---
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|||||||
76
extension/src/shared/__tests__/color-scheme.test.ts
Normal file
76
extension/src/shared/__tests__/color-scheme.test.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
loadColorScheme, saveColorScheme, resetColorScheme, applyColorScheme,
|
||||||
|
DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR,
|
||||||
|
} from '../color-scheme';
|
||||||
|
|
||||||
|
function mockChromeStorage(initial: any = {}) {
|
||||||
|
const store = { ...initial };
|
||||||
|
(global as any).chrome = {
|
||||||
|
storage: {
|
||||||
|
sync: {
|
||||||
|
get: vi.fn((key: string) => Promise.resolve(
|
||||||
|
key in store ? { [key]: store[key] } : {})),
|
||||||
|
set: vi.fn((kv: any) => { Object.assign(store, kv); return Promise.resolve(); }),
|
||||||
|
remove: vi.fn((key: string) => { delete store[key]; return Promise.resolve(); }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('color-scheme storage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// happy-dom provides document globally; reset inline styles between tests
|
||||||
|
document.documentElement.removeAttribute('style');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('load returns defaults when storage is empty', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
expect(scheme.digit_color).toBe(DEFAULT_DIGIT_COLOR);
|
||||||
|
expect(scheme.symbol_color).toBe(DEFAULT_SYMBOL_COLOR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('load returns stored values when present', async () => {
|
||||||
|
mockChromeStorage({
|
||||||
|
password_display_scheme: { digit_color: '#123456', symbol_color: '#abcdef' },
|
||||||
|
});
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
expect(scheme.digit_color).toBe('#123456');
|
||||||
|
expect(scheme.symbol_color).toBe('#abcdef');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save round-trips', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
await saveColorScheme({ digit_color: '#111111', symbol_color: '#222222' });
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
expect(scheme).toEqual({ digit_color: '#111111', symbol_color: '#222222' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reset removes the storage key', async () => {
|
||||||
|
const store = mockChromeStorage({
|
||||||
|
password_display_scheme: { digit_color: '#000000', symbol_color: '#ffffff' },
|
||||||
|
});
|
||||||
|
await resetColorScheme();
|
||||||
|
expect(store.password_display_scheme).toBeUndefined();
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
expect(scheme.digit_color).toBe(DEFAULT_DIGIT_COLOR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('apply sets CSS custom properties on document.documentElement', async () => {
|
||||||
|
mockChromeStorage({
|
||||||
|
password_display_scheme: { digit_color: '#deadbe', symbol_color: '#feed00' },
|
||||||
|
});
|
||||||
|
await applyColorScheme();
|
||||||
|
const root = document.documentElement.style;
|
||||||
|
expect(root.getPropertyValue('--relicario-pwd-digit-color').trim()).toBe('#deadbe');
|
||||||
|
expect(root.getPropertyValue('--relicario-pwd-symbol-color').trim()).toBe('#feed00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save rejects malformed hex values', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
await expect(saveColorScheme({ digit_color: 'not-a-color', symbol_color: '#ffffff' }))
|
||||||
|
.rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
44
extension/src/shared/__tests__/error-copy.test.ts
Normal file
44
extension/src/shared/__tests__/error-copy.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { ERROR_COPY, lookupErrorCopy } from '../error-copy';
|
||||||
|
|
||||||
|
const repoRoot = resolve(__dirname, '../../../..');
|
||||||
|
|
||||||
|
function discoverCodes(): Set<string> {
|
||||||
|
const out = execSync(
|
||||||
|
`grep -rohE "ok: false, error: '[^']+'" extension/src/service-worker/ \
|
||||||
|
--include="*.ts" --exclude-dir=__tests__`,
|
||||||
|
{ cwd: repoRoot, encoding: 'utf-8' },
|
||||||
|
);
|
||||||
|
const codes = new Set<string>();
|
||||||
|
for (const line of out.split('\n')) {
|
||||||
|
const m = line.match(/error: '([^']+)'/);
|
||||||
|
if (m) codes.add(m[1]);
|
||||||
|
}
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ERROR_COPY', () => {
|
||||||
|
it('contains an entry for every error code returned by the service worker', () => {
|
||||||
|
const discovered = discoverCodes();
|
||||||
|
expect(discovered.size).toBeGreaterThan(0);
|
||||||
|
const missing: string[] = [];
|
||||||
|
for (const code of discovered) {
|
||||||
|
if (!ERROR_COPY[code]) missing.push(code);
|
||||||
|
}
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lookupErrorCopy returns the mapped entry for known codes', () => {
|
||||||
|
const copy = lookupErrorCopy('vault_locked');
|
||||||
|
expect(copy.title).toBe('Vault locked');
|
||||||
|
expect(copy.body).toMatch(/unlock/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lookupErrorCopy falls back to a generic shape for unknown codes', () => {
|
||||||
|
const copy = lookupErrorCopy('made_up_code_xyz');
|
||||||
|
expect(copy.title).toBe('Something went wrong');
|
||||||
|
expect(copy.body).toContain('made_up_code_xyz');
|
||||||
|
});
|
||||||
|
});
|
||||||
60
extension/src/shared/__tests__/password-coloring.test.ts
Normal file
60
extension/src/shared/__tests__/password-coloring.test.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { colorizePassword, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER } from '../password-coloring';
|
||||||
|
|
||||||
|
describe('colorizePassword', () => {
|
||||||
|
|
||||||
|
function classes(frag: DocumentFragment): string[] {
|
||||||
|
return Array.from(frag.querySelectorAll('span')).map(s => s.className);
|
||||||
|
}
|
||||||
|
function texts(frag: DocumentFragment): string[] {
|
||||||
|
return Array.from(frag.querySelectorAll('span')).map(s => s.textContent ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns empty fragment for empty input', () => {
|
||||||
|
const frag = colorizePassword('');
|
||||||
|
expect(frag.childNodes.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies a mixed-class run', () => {
|
||||||
|
const frag = colorizePassword('aB3$xY');
|
||||||
|
expect(classes(frag)).toEqual([PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER]);
|
||||||
|
expect(texts(frag)).toEqual(['aB', '3', '$', 'xY']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all-letters produces a single letter span', () => {
|
||||||
|
const frag = colorizePassword('passwd');
|
||||||
|
expect(classes(frag)).toEqual([PWD_LETTER]);
|
||||||
|
expect(texts(frag)).toEqual(['passwd']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all-digits produces a single digit span', () => {
|
||||||
|
const frag = colorizePassword('123456');
|
||||||
|
expect(classes(frag)).toEqual([PWD_DIGIT]);
|
||||||
|
expect(texts(frag)).toEqual(['123456']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all-symbols produces a single symbol span', () => {
|
||||||
|
const frag = colorizePassword('!@#$%^');
|
||||||
|
expect(classes(frag)).toEqual([PWD_SYMBOL]);
|
||||||
|
expect(texts(frag)).toEqual(['!@#$%^']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies unicode letters as letters', () => {
|
||||||
|
const frag = colorizePassword('áñü');
|
||||||
|
expect(classes(frag)).toEqual([PWD_LETTER]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies whitespace as symbol', () => {
|
||||||
|
const frag = colorizePassword('a b');
|
||||||
|
expect(classes(frag)).toEqual([PWD_LETTER, PWD_SYMBOL, PWD_LETTER]);
|
||||||
|
expect(texts(frag)).toEqual(['a', ' ', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('representative password snapshot: aB3$xY7&_!', () => {
|
||||||
|
const frag = colorizePassword('aB3$xY7&_!');
|
||||||
|
expect(classes(frag)).toEqual([
|
||||||
|
PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER, PWD_DIGIT, PWD_SYMBOL,
|
||||||
|
]);
|
||||||
|
expect(texts(frag)).toEqual(['aB', '3', '$', 'xY', '7', '&_!']);
|
||||||
|
});
|
||||||
|
});
|
||||||
48
extension/src/shared/color-scheme.ts
Normal file
48
extension/src/shared/color-scheme.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
export const DEFAULT_DIGIT_COLOR = '#2563eb';
|
||||||
|
export const DEFAULT_SYMBOL_COLOR = '#dc2626';
|
||||||
|
const STORAGE_KEY = 'password_display_scheme';
|
||||||
|
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
||||||
|
|
||||||
|
export interface ColorScheme {
|
||||||
|
digit_color: string;
|
||||||
|
symbol_color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SCHEME: ColorScheme = {
|
||||||
|
digit_color: DEFAULT_DIGIT_COLOR,
|
||||||
|
symbol_color: DEFAULT_SYMBOL_COLOR,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isValid(s: ColorScheme): boolean {
|
||||||
|
return HEX_RE.test(s.digit_color) && HEX_RE.test(s.symbol_color);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadColorScheme(): Promise<ColorScheme> {
|
||||||
|
const result = await chrome.storage.sync.get(STORAGE_KEY);
|
||||||
|
const stored = result[STORAGE_KEY] as Partial<ColorScheme> | undefined;
|
||||||
|
if (!stored) return { ...DEFAULT_SCHEME };
|
||||||
|
return {
|
||||||
|
digit_color: typeof stored.digit_color === 'string' && HEX_RE.test(stored.digit_color)
|
||||||
|
? stored.digit_color : DEFAULT_DIGIT_COLOR,
|
||||||
|
symbol_color: typeof stored.symbol_color === 'string' && HEX_RE.test(stored.symbol_color)
|
||||||
|
? stored.symbol_color : DEFAULT_SYMBOL_COLOR,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveColorScheme(scheme: ColorScheme): Promise<void> {
|
||||||
|
if (!isValid(scheme)) {
|
||||||
|
throw new Error('Invalid color values; expected #rrggbb hex strings.');
|
||||||
|
}
|
||||||
|
await chrome.storage.sync.set({ [STORAGE_KEY]: scheme });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetColorScheme(): Promise<void> {
|
||||||
|
await chrome.storage.sync.remove(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyColorScheme(): Promise<void> {
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
const root = document.documentElement.style;
|
||||||
|
root.setProperty('--relicario-pwd-digit-color', scheme.digit_color);
|
||||||
|
root.setProperty('--relicario-pwd-symbol-color', scheme.symbol_color);
|
||||||
|
}
|
||||||
102
extension/src/shared/error-copy.ts
Normal file
102
extension/src/shared/error-copy.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
export interface ErrorCta {
|
||||||
|
label: string;
|
||||||
|
action?: 'unlock' | 'reload_extension' | 'open_setup';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorCopy {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
cta?: ErrorCta;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNLOCK_CTA: ErrorCta = { label: 'Unlock vault', action: 'unlock' };
|
||||||
|
|
||||||
|
export const ERROR_COPY: Record<string, ErrorCopy> = {
|
||||||
|
vault_locked: {
|
||||||
|
title: 'Vault locked',
|
||||||
|
body: 'Unlock your vault to continue.',
|
||||||
|
cta: UNLOCK_CTA,
|
||||||
|
},
|
||||||
|
unauthorized_sender: {
|
||||||
|
title: 'Action not allowed',
|
||||||
|
body: 'This action is not allowed from here.',
|
||||||
|
},
|
||||||
|
unknown_message_type: {
|
||||||
|
title: 'Internal error',
|
||||||
|
body: 'The extension received an unknown request — try reloading.',
|
||||||
|
cta: { label: 'Reload extension', action: 'reload_extension' },
|
||||||
|
},
|
||||||
|
origin_mismatch: {
|
||||||
|
title: 'Wrong site',
|
||||||
|
body: 'This login belongs to a different site — refusing to leak credentials cross-origin.',
|
||||||
|
},
|
||||||
|
not_a_login: {
|
||||||
|
title: 'Not a login',
|
||||||
|
body: 'That item does not have a username and password to fill.',
|
||||||
|
},
|
||||||
|
no_totp: {
|
||||||
|
title: 'No 2FA on this item',
|
||||||
|
body: 'This item does not have a TOTP secret configured.',
|
||||||
|
},
|
||||||
|
invalid_sender_url: {
|
||||||
|
title: 'Cannot read tab URL',
|
||||||
|
body: 'The current tab has no recognizable URL — try reloading the page.',
|
||||||
|
},
|
||||||
|
tab_navigated: {
|
||||||
|
title: 'Tab changed',
|
||||||
|
body: 'The browser tab changed before the action could complete — try again.',
|
||||||
|
},
|
||||||
|
captured_tab_gone: {
|
||||||
|
title: 'Tab is gone',
|
||||||
|
body: 'The browser tab closed before the action could complete — try again.',
|
||||||
|
},
|
||||||
|
item_not_found: {
|
||||||
|
title: 'Item not found',
|
||||||
|
body: 'That item is no longer in the vault — it may have been deleted from another device.',
|
||||||
|
},
|
||||||
|
attachment_not_found: {
|
||||||
|
title: 'Attachment missing',
|
||||||
|
body: 'The attachment is referenced in the item but is not present in the vault.',
|
||||||
|
},
|
||||||
|
upload_failed: {
|
||||||
|
title: 'Upload failed',
|
||||||
|
body: 'Could not upload the attachment — check your connection and try again.',
|
||||||
|
},
|
||||||
|
download_failed: {
|
||||||
|
title: 'Download failed',
|
||||||
|
body: 'Could not download the attachment — check your connection and try again.',
|
||||||
|
},
|
||||||
|
'invalid base32 secret': {
|
||||||
|
title: 'Invalid secret',
|
||||||
|
body: 'The TOTP secret must be valid Base32 (letters A-Z and digits 2-7 only).',
|
||||||
|
},
|
||||||
|
'no items to import': {
|
||||||
|
title: 'Nothing to import',
|
||||||
|
body: 'The CSV did not contain any importable items.',
|
||||||
|
},
|
||||||
|
'no reference image stored locally': {
|
||||||
|
title: 'No reference image',
|
||||||
|
body: 'This device has no reference image saved locally — re-attach the device or restore from backup.',
|
||||||
|
},
|
||||||
|
'remote already contains a Relicario vault': {
|
||||||
|
title: 'Vault already exists',
|
||||||
|
body: 'The selected repository already contains a vault — use Attach existing instead of Create new.',
|
||||||
|
},
|
||||||
|
'Extension not configured. Run setup first.': {
|
||||||
|
title: 'Extension not configured',
|
||||||
|
body: 'Run setup before using this action.',
|
||||||
|
cta: { label: 'Open setup', action: 'open_setup' },
|
||||||
|
},
|
||||||
|
'Reference image not set. Run setup first.': {
|
||||||
|
title: 'Reference image missing',
|
||||||
|
body: 'Run setup to save your reference image.',
|
||||||
|
cta: { label: 'Open setup', action: 'open_setup' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function lookupErrorCopy(code: string): ErrorCopy {
|
||||||
|
return ERROR_COPY[code] ?? {
|
||||||
|
title: 'Something went wrong',
|
||||||
|
body: code,
|
||||||
|
};
|
||||||
|
}
|
||||||
35
extension/src/shared/password-coloring.ts
Normal file
35
extension/src/shared/password-coloring.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export const PWD_DIGIT = 'pwd-digit';
|
||||||
|
export const PWD_SYMBOL = 'pwd-symbol';
|
||||||
|
export const PWD_LETTER = 'pwd-letter';
|
||||||
|
|
||||||
|
type Class = typeof PWD_DIGIT | typeof PWD_SYMBOL | typeof PWD_LETTER;
|
||||||
|
|
||||||
|
function classify(ch: string): Class {
|
||||||
|
if (/^\d$/.test(ch)) return PWD_DIGIT;
|
||||||
|
if (/^\p{L}$/u.test(ch)) return PWD_LETTER;
|
||||||
|
return PWD_SYMBOL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function colorizePassword(text: string): DocumentFragment {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
if (text.length === 0) return frag;
|
||||||
|
|
||||||
|
const codepoints = Array.from(text);
|
||||||
|
let runStart = 0;
|
||||||
|
let runClass = classify(codepoints[0]);
|
||||||
|
|
||||||
|
for (let i = 1; i <= codepoints.length; i++) {
|
||||||
|
const c = i < codepoints.length ? classify(codepoints[i]) : null;
|
||||||
|
if (c !== runClass) {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = runClass;
|
||||||
|
span.textContent = codepoints.slice(runStart, i).join('');
|
||||||
|
frag.appendChild(span);
|
||||||
|
if (c !== null) {
|
||||||
|
runStart = i;
|
||||||
|
runClass = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
@@ -38,6 +38,10 @@
|
|||||||
|
|
||||||
/* Focus */
|
/* Focus */
|
||||||
--focus-ring: 0 0 0 2px var(--gold-ring);
|
--focus-ring: 0 0 0 2px var(--gold-ring);
|
||||||
|
|
||||||
|
/* Password coloring (P1) */
|
||||||
|
--relicario-pwd-digit-color: #2563eb;
|
||||||
|
--relicario-pwd-symbol-color: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -144,6 +148,36 @@ body {
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
/* rgba channels derived from --danger (#ab2b20 = rgb(171, 43, 32)) */
|
||||||
|
border: 1px solid rgba(171, 43, 32, 0.4);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(171, 43, 32, 0.08);
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.error-block .error-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.error-block .error-body {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.error-block .error-cta {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Password character-class coloring */
|
||||||
|
.pwd-digit { color: var(--relicario-pwd-digit-color); }
|
||||||
|
.pwd-symbol { color: var(--relicario-pwd-symbol-color); }
|
||||||
|
.pwd-letter { color: inherit; }
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -1592,6 +1626,20 @@ textarea {
|
|||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.form-grid { grid-template-columns: 1fr; }
|
.form-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* P3: lower form sections constrained to the same envelope as .form-grid.
|
||||||
|
Gated on surface === 'fullscreen' in login.ts; popup unaffected. */
|
||||||
|
.form-lower {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.form-lower > .form-group,
|
||||||
|
.form-lower > .disclosure,
|
||||||
|
.form-lower > .attachments-disclosure,
|
||||||
|
.form-lower > .form-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.form-col {
|
.form-col {
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
ItemId, ItemType, ManifestEntry, Item, VaultSettings, GeneratorRequest,
|
ItemId, ItemType, ManifestEntry, Item, VaultSettings, GeneratorRequest,
|
||||||
} from '../shared/types';
|
} from '../shared/types';
|
||||||
import { registerHost } from '../shared/state';
|
import { registerHost } from '../shared/state';
|
||||||
|
import { lookupErrorCopy, type ErrorCta } from '../shared/error-copy';
|
||||||
import { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK } from '../shared/glyphs';
|
import { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK } from '../shared/glyphs';
|
||||||
import { renderItemDetail } from '../popup/components/item-detail';
|
import { renderItemDetail } from '../popup/components/item-detail';
|
||||||
import { renderItemForm } from '../popup/components/item-form';
|
import { renderItemForm } from '../popup/components/item-form';
|
||||||
@@ -19,6 +20,7 @@ import { renderVaultSettings as renderVaultSettingsView } from '../popup/compone
|
|||||||
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
|
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
|
||||||
import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel';
|
import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel';
|
||||||
import { renderImportPanel, teardown as teardownImport } from './components/import-panel';
|
import { renderImportPanel, teardown as teardownImport } from './components/import-panel';
|
||||||
|
import { applyColorScheme } from '../shared/color-scheme';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -41,6 +43,21 @@ function escapeHtml(str: string): string {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderErrorBlock(code: string | null | undefined): string {
|
||||||
|
if (!code) return '';
|
||||||
|
const copy = lookupErrorCopy(code);
|
||||||
|
const ctaHtml = copy.cta
|
||||||
|
? `<button class="btn btn-primary error-cta" data-cta="${escapeHtml(copy.cta.action ?? '')}">${escapeHtml(copy.cta.label)}</button>`
|
||||||
|
: '';
|
||||||
|
return `
|
||||||
|
<div class="error error-block">
|
||||||
|
<div class="error-title">${escapeHtml(copy.title)}</div>
|
||||||
|
<div class="error-body">${escapeHtml(copy.body)}</div>
|
||||||
|
${ctaHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function typeIcon(t: ItemType): string {
|
function typeIcon(t: ItemType): string {
|
||||||
switch (t) {
|
switch (t) {
|
||||||
case 'login': return '\u{1F511}'; // key
|
case 'login': return '\u{1F511}'; // key
|
||||||
@@ -199,7 +216,7 @@ function renderLockScreen(app: HTMLElement): void {
|
|||||||
<div class="vault-lock-screen__form">
|
<div class="vault-lock-screen__form">
|
||||||
<input type="password" id="vault-passphrase" placeholder="passphrase" autocomplete="off" />
|
<input type="password" id="vault-passphrase" placeholder="passphrase" autocomplete="off" />
|
||||||
<button class="btn btn-primary" id="vault-unlock-btn" style="width:100%;">unlock</button>
|
<button class="btn btn-primary" id="vault-unlock-btn" style="width:100%;">unlock</button>
|
||||||
${state.error ? `<div class="error" style="text-align:center;">${escapeHtml(state.error)}</div>` : ''}
|
${renderErrorBlock(state.error)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -592,6 +609,36 @@ async function loadManifest(): Promise<void> {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
await applyColorScheme();
|
||||||
|
|
||||||
|
chrome.storage.onChanged.addListener((changes, area) => {
|
||||||
|
if (area === 'sync' && 'password_display_scheme' in changes) {
|
||||||
|
void applyColorScheme();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delegated handler for .error-cta buttons — set up once on the stable root.
|
||||||
|
const app = document.getElementById('vault-app')!;
|
||||||
|
app.addEventListener('click', (e) => {
|
||||||
|
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.error-cta');
|
||||||
|
if (!btn) return;
|
||||||
|
const cta = btn.dataset.cta as ErrorCta['action'];
|
||||||
|
switch (cta) {
|
||||||
|
case 'unlock': {
|
||||||
|
document.getElementById('vault-passphrase')?.focus();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'open_setup': {
|
||||||
|
void chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'reload_extension': {
|
||||||
|
chrome.runtime.reload();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Check if already unlocked
|
// Check if already unlocked
|
||||||
const resp = await sendMessage({ type: 'is_unlocked' });
|
const resp = await sendMessage({ type: 'is_unlocked' });
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
|
|||||||
@@ -12,5 +12,5 @@
|
|||||||
"baseUrl": "."
|
"baseUrl": "."
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist", "wasm", "src/**/__tests__/**"]
|
"exclude": ["node_modules", "dist", "wasm", "src/**/__tests__/**", "src/__stubs__/**"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
// Stub the runtime-only WASM module so unit tests can import setup.ts.
|
||||||
|
'../relicario_wasm.js': path.resolve(__dirname, 'src/__stubs__/relicario_wasm.stub.ts'),
|
||||||
|
'relicario-wasm': path.resolve(__dirname, 'src/__stubs__/relicario_wasm.stub.ts'),
|
||||||
|
},
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: 'happy-dom',
|
environment: 'happy-dom',
|
||||||
include: ['src/**/__tests__/**/*.test.ts'],
|
include: ['src/**/__tests__/**/*.test.ts'],
|
||||||
|
|||||||
Reference in New Issue
Block a user