11 Commits

Author SHA1 Message Date
adlee-was-taken
33d2a4a311 feat(ext/setup): wizard Style C progress track, glyph mode icons, recovery QR banner
- Replace dot-based progress indicator with colored horizontal segment track
  (completed=green, active=gold, pending=border) via renderProgressTrack()
- Add SETUP_STEP_NAMES constant for track segment titles
- Update Step 0 mode cards with glyph icons (◈ create, ⌥ attach)
- Add recovery QR banner in Step 5 (new-vault only, verifiedHandle present)
  with Generate now / Skip buttons wired in attachStep5()
- Add CSS for .setup-progress-track, .setup-progress-segment variants,
  and .recovery-qr-banner to styles.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:17:05 -04:00
adlee-was-taken
f17944a404 fix(core,wasm): correct QR version comment, expect msg, zeroize image_secret in closure 2026-05-03 21:09:02 -04:00
adlee-was-taken
4851857070 feat(ext/settings): settings-security.ts three-state recovery QR + devices component
- Add settings-security.ts with renderSecuritySection / teardownSecuritySection
- Three states: amber warning (no QR), green status (QR set up), modal overlay (show/print SVG)
- Device list with inline revoke; passphrase collected via prompt()
- QR payload never written to chrome.storage; only recovery_qr_generated_at timestamp stored
- Add generate_recovery_qr / unwrap_recovery_qr message types to messages.ts + POPUP_ONLY_TYPES
- Add SW handlers in popup-only.ts delegating to wasm_generate_recovery_qr / wasm_unwrap_recovery_qr
- Declare wasm_generate_recovery_qr and wasm_unwrap_recovery_qr in wasm.d.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:06:43 -04:00
adlee-was-taken
a6071b4c0c feat(cli): recovery-qr generate / unwrap subcommands
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:01:29 -04:00
adlee-was-taken
ada00895d4 feat(wasm): expose generate_recovery_qr and unwrap_recovery_qr bindings 2026-05-03 20:57:55 -04:00
adlee-was-taken
42b746f9af feat(wasm): session stores image_secret for recovery QR generation 2026-05-03 20:56:39 -04:00
adlee-was-taken
762a008171 test(core): recovery_qr roundtrip + error cases 2026-05-03 20:53:59 -04:00
adlee-was-taken
f93bce7388 chore(core): re-export recovery_qr module 2026-05-03 20:51:36 -04:00
adlee-was-taken
8eabaf5f31 feat(core): recovery_qr generate + unwrap + SVG functions 2026-05-03 20:51:33 -04:00
adlee-was-taken
04142dc116 feat(core): add derive_master_key_raw + RecoveryQr error variant 2026-05-03 20:51:29 -04:00
adlee-was-taken
8739f1f67b chore(core): add qrcode dependency for recovery QR 2026-05-03 20:48:38 -04:00
20 changed files with 953 additions and 519 deletions

1
Cargo.lock generated
View File

@@ -2198,6 +2198,7 @@ dependencies = [
"hex",
"hmac",
"image",
"qrcode",
"rand",
"serde",
"serde_json",

View File

@@ -28,10 +28,10 @@ clap_complete = "4"
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
rqrr = "0.7"
reqwest = { version = "0.12", features = ["blocking", "json"] }
qrcode = { version = "0.14", features = ["svg"] }
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"
qrcode = "0.14"
serde_json = "1"

View File

@@ -196,6 +196,12 @@ enum Commands {
#[command(subcommand)]
action: DeviceAction,
},
/// Recovery QR operations — generate or unwrap the 2FA recovery code.
RecoveryQr {
#[command(subcommand)]
cmd: RecoveryQrCmd,
},
}
#[derive(Subcommand)]
@@ -403,6 +409,14 @@ enum DeviceAction {
List,
}
#[derive(clap::Subcommand)]
enum RecoveryQrCmd {
/// Generate a recovery QR code and display it as ASCII art in the terminal.
Generate,
/// Unwrap a recovery QR payload (base64) to recover the image_secret as hex.
Unwrap,
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
@@ -436,6 +450,7 @@ fn main() -> Result<()> {
}
Commands::Rate { passphrase } => cmd_rate(passphrase),
Commands::Device { action } => cmd_device(action),
Commands::RecoveryQr { cmd } => cmd_recovery_qr(cmd),
}
}
@@ -2560,3 +2575,67 @@ fn cmd_device(action: DeviceAction) -> Result<()> {
}
}
}
fn cmd_recovery_qr(cmd: RecoveryQrCmd) -> Result<()> {
match cmd {
RecoveryQrCmd::Generate => cmd_recovery_qr_generate(),
RecoveryQrCmd::Unwrap => cmd_recovery_qr_unwrap(),
}
}
fn cmd_recovery_qr_generate() -> Result<()> {
use relicario_core::{generate_recovery_qr, imgsecret};
use zeroize::Zeroizing;
let image_path = crate::session::get_image_path()?;
let image_bytes = std::fs::read(&image_path)
.with_context(|| format!("read reference image {}", image_path.display()))?;
let image_secret = imgsecret::extract(&image_bytes)
.context("extract image secret")?;
let passphrase = Zeroizing::new(
rpassword::prompt_password("Enter vault passphrase: ")
.context("read passphrase")?
);
let payload = generate_recovery_qr(passphrase.as_str(), &image_secret)
.map_err(|e| anyhow::anyhow!("{e}"))?;
use qrcode::{EcLevel, QrCode, render::unicode};
let code = QrCode::with_error_correction_level(payload.as_bytes(), EcLevel::M)
.expect("valid payload");
let image = code
.render::<unicode::Dense1x2>()
.dark_color(unicode::Dense1x2::Dark)
.light_color(unicode::Dense1x2::Light)
.build();
println!("{image}");
println!("Recovery QR generated. Print or photograph this code and store it securely.");
println!("The QR has NOT been saved to disk.");
Ok(())
}
fn cmd_recovery_qr_unwrap() -> Result<()> {
use relicario_core::unwrap_recovery_qr;
use std::io::BufRead;
use zeroize::Zeroizing;
println!("Paste the base64 recovery QR payload and press Enter:");
let stdin = std::io::stdin();
let payload_b64 = stdin.lock().lines().next()
.context("no input")??;
let payload_b64 = payload_b64.trim().to_owned();
let bytes = data_encoding::BASE64.decode(payload_b64.as_bytes())
.map_err(|e| anyhow::anyhow!("base64 decode: {e}"))?;
let passphrase = Zeroizing::new(
rpassword::prompt_password("Enter passphrase: ")
.context("read passphrase")?
);
let secret = unwrap_recovery_qr(&bytes, passphrase.as_str())
.map_err(|e| anyhow::anyhow!("{e}"))?;
println!("image_secret: {}", hex::encode(secret.as_ref()));
Ok(())
}

View File

@@ -31,5 +31,6 @@ zstd = { version = "0.13", default-features = false }
tar = { version = "0.4", default-features = false }
base64 = "0.22"
csv = "1"
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
[dev-dependencies]

View File

@@ -243,6 +243,23 @@ pub fn derive_master_key(
Ok(output)
}
/// Like `derive_master_key` but takes an already-assembled `input` byte slice directly,
/// allowing callers to apply their own domain separation before KDF.
pub fn derive_master_key_raw(
input: &[u8],
salt: &[u8; 32],
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
let argon2_params = Params::new(params.argon2_m, params.argon2_t, params.argon2_p, Some(32))
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
let mut output = Zeroizing::new([0u8; 32]);
argon2
.hash_password_into(input, salt, output.as_mut())
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -119,6 +119,10 @@ pub enum RelicarioError {
/// immediately. Use TOTP instead.
#[error("HOTP is not supported: counter persistence requires vault save after each use")]
HotpNotSupported,
/// Recovery QR generation or parsing failed.
#[error("recovery QR: {0}")]
RecoveryQr(String),
}
/// Crate-wide result alias, reducing boilerplate in function signatures.

View File

@@ -89,3 +89,11 @@ pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign,
pub mod tar_safe;
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
pub mod recovery_qr;
pub use recovery_qr::{
generate_recovery_qr, generate_recovery_qr_with_params,
recovery_qr_to_svg,
unwrap_recovery_qr, unwrap_recovery_qr_with_params,
RecoveryQrPayload,
};

View File

@@ -0,0 +1,129 @@
use chacha20poly1305::{XChaCha20Poly1305, Key, KeyInit, aead::Aead};
use rand::RngCore;
use unicode_normalization::UnicodeNormalization;
use zeroize::Zeroizing;
use crate::{crypto::KdfParams, error::{RelicarioError, Result}};
const MAGIC: &[u8; 4] = b"RREC";
const VERSION: u8 = 0x01;
const PAYLOAD_LEN: usize = 4 + 1 + 32 + 24 + 48; // 109
pub struct RecoveryQrPayload {
bytes: [u8; PAYLOAD_LEN],
}
impl RecoveryQrPayload {
pub fn as_bytes(&self) -> &[u8; PAYLOAD_LEN] {
&self.bytes
}
}
fn recovery_kdf_input(passphrase: &str) -> Vec<u8> {
let nfc: String = passphrase.nfc().collect();
let nfc_bytes = nfc.as_bytes();
let prefix = b"relicario-recovery-v1\0";
let mut input = Vec::with_capacity(prefix.len() + 8 + nfc_bytes.len());
input.extend_from_slice(prefix);
input.extend_from_slice(&(nfc_bytes.len() as u64).to_be_bytes());
input.extend_from_slice(nfc_bytes);
input
}
fn production_params() -> KdfParams {
KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }
}
fn derive_wrap_key(
passphrase: &str,
kdf_salt: &[u8; 32],
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
let input = recovery_kdf_input(passphrase);
crate::crypto::derive_master_key_raw(&input, kdf_salt, params)
}
pub fn generate_recovery_qr(
passphrase: &str,
image_secret: &[u8; 32],
) -> Result<RecoveryQrPayload> {
generate_recovery_qr_with_params(passphrase, image_secret, &production_params())
}
#[doc(hidden)]
pub fn generate_recovery_qr_with_params(
passphrase: &str,
image_secret: &[u8; 32],
params: &KdfParams,
) -> Result<RecoveryQrPayload> {
let mut kdf_salt = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut kdf_salt);
let mut wrap_nonce = [0u8; 24];
rand::rngs::OsRng.fill_bytes(&mut wrap_nonce);
let wrap_key = derive_wrap_key(passphrase, &kdf_salt, params)?;
let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref()));
let nonce = chacha20poly1305::XNonce::from_slice(&wrap_nonce);
let ciphertext = cipher.encrypt(nonce, image_secret.as_ref())
.map_err(|_| RelicarioError::RecoveryQr("wrap encrypt failed".into()))?;
let mut bytes = [0u8; PAYLOAD_LEN];
let mut pos = 0;
bytes[pos..pos+4].copy_from_slice(MAGIC); pos += 4;
bytes[pos] = VERSION; pos += 1;
bytes[pos..pos+32].copy_from_slice(&kdf_salt); pos += 32;
bytes[pos..pos+24].copy_from_slice(&wrap_nonce); pos += 24;
bytes[pos..pos+48].copy_from_slice(&ciphertext);
Ok(RecoveryQrPayload { bytes })
}
pub fn unwrap_recovery_qr(
payload_bytes: &[u8],
passphrase: &str,
) -> Result<Zeroizing<[u8; 32]>> {
unwrap_recovery_qr_with_params(payload_bytes, passphrase, &production_params())
}
#[doc(hidden)]
pub fn unwrap_recovery_qr_with_params(
payload_bytes: &[u8],
passphrase: &str,
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
if payload_bytes.len() != PAYLOAD_LEN {
return Err(RelicarioError::RecoveryQr(
format!("payload must be {PAYLOAD_LEN} bytes, got {}", payload_bytes.len())
));
}
if &payload_bytes[0..4] != MAGIC {
return Err(RelicarioError::RecoveryQr("bad magic".into()));
}
if payload_bytes[4] != VERSION {
return Err(RelicarioError::RecoveryQr(
format!("unsupported version 0x{:02x}", payload_bytes[4])
));
}
let kdf_salt: &[u8; 32] = payload_bytes[5..37].try_into().expect("slice length validated above");
let wrap_nonce = &payload_bytes[37..61];
let ciphertext = &payload_bytes[61..109];
let wrap_key = derive_wrap_key(passphrase, kdf_salt, params)?;
let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref()));
let nonce = chacha20poly1305::XNonce::from_slice(wrap_nonce);
let plaintext = cipher.decrypt(nonce, ciphertext)
.map_err(|_| RelicarioError::Decrypt)?;
let mut out = Zeroizing::new([0u8; 32]);
out.copy_from_slice(&plaintext);
Ok(out)
}
pub fn recovery_qr_to_svg(payload: &RecoveryQrPayload) -> String {
use qrcode::{QrCode, EcLevel};
let code = QrCode::with_error_correction_level(payload.bytes.as_ref(), EcLevel::M)
.expect("109 bytes fits well within QR v40 capacity at EcLevel::M");
code.render::<qrcode::render::svg::Color>()
.min_dimensions(140, 140)
.build()
}

View File

@@ -0,0 +1,60 @@
use relicario_core::{
crypto::KdfParams,
generate_recovery_qr_with_params, recovery_qr_to_svg, unwrap_recovery_qr_with_params,
};
fn fast_params() -> KdfParams {
KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }
}
fn test_secret() -> [u8; 32] {
let mut s = [0u8; 32];
for (i, b) in s.iter_mut().enumerate() { *b = i as u8; }
s
}
#[test]
fn roundtrip_recovers_image_secret() {
let passphrase = "correct-horse-battery-staple";
let secret = test_secret();
let payload = generate_recovery_qr_with_params(passphrase, &secret, &fast_params())
.expect("generate ok");
let recovered = unwrap_recovery_qr_with_params(payload.as_bytes(), passphrase, &fast_params())
.expect("unwrap ok");
assert_eq!(recovered.as_ref(), &secret);
}
#[test]
fn wrong_passphrase_fails_decrypt() {
let secret = test_secret();
let payload = generate_recovery_qr_with_params("right-pass", &secret, &fast_params())
.expect("generate ok");
let result = unwrap_recovery_qr_with_params(payload.as_bytes(), "wrong-pass", &fast_params());
assert!(result.is_err());
}
#[test]
fn payload_is_109_bytes() {
let secret = test_secret();
let payload = generate_recovery_qr_with_params("test", &secret, &fast_params())
.expect("generate ok");
assert_eq!(payload.as_bytes().len(), 109);
}
#[test]
fn svg_output_is_non_empty_xml() {
let secret = test_secret();
let payload = generate_recovery_qr_with_params("test", &secret, &fast_params())
.expect("generate ok");
let svg = recovery_qr_to_svg(&payload);
assert!(svg.contains("<svg"), "SVG output should contain <svg tag");
assert!(!svg.is_empty());
}
#[test]
fn bad_magic_returns_error() {
let mut bad = [0u8; 109];
bad[0..4].copy_from_slice(b"NOPE");
let result = unwrap_recovery_qr_with_params(&bad, "pass", &fast_params());
assert!(result.is_err());
}

View File

@@ -8,6 +8,7 @@ mod session;
mod device;
use wasm_bindgen::prelude::*;
use zeroize::Zeroizing;
use relicario_core::{derive_master_key, imgsecret, KdfParams};
@@ -36,7 +37,8 @@ pub fn unlock(
.map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, &params)
.map_err(|e| JsError::new(&e.to_string()))?;
let handle = session::insert(master_key);
let stored_secret = Zeroizing::new(image_secret);
let handle = session::insert(master_key, stored_secret);
Ok(SessionHandle(handle))
}
@@ -484,6 +486,39 @@ pub fn parse_lastpass_csv_json(csv_bytes: &[u8]) -> Result<String, JsError> {
Ok(json.to_string())
}
// ── Recovery QR bindings ─────────────────────────────────────────────────────
use relicario_core::{generate_recovery_qr, recovery_qr_to_svg, unwrap_recovery_qr};
/// Generate a recovery QR SVG for the current session.
/// Returns the SVG string. The passphrase wraps the image_secret under a
/// separate key (domain-separated from the master key derivation).
#[wasm_bindgen]
pub fn wasm_generate_recovery_qr(
handle: &SessionHandle,
passphrase: &str,
) -> Result<String, JsError> {
let payload = session::with_image_secret(handle.0, |s| generate_recovery_qr(passphrase, s))
.ok_or_else(|| JsError::new("invalid or locked session handle"))?
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(recovery_qr_to_svg(&payload))
}
/// Unwrap a recovery QR payload (base64-encoded 109-byte blob) using the passphrase.
/// Returns the raw image_secret bytes (32 bytes).
#[wasm_bindgen]
pub fn wasm_unwrap_recovery_qr(
payload_b64: &str,
passphrase: &str,
) -> Result<Vec<u8>, JsError> {
use base64::{engine::general_purpose::STANDARD, Engine};
let bytes = STANDARD.decode(payload_b64)
.map_err(|e| JsError::new(&format!("base64: {e}")))?;
let recovered = unwrap_recovery_qr(&bytes, passphrase)
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(recovered.to_vec())
}
#[cfg(test)]
mod session_tests {
use super::*;
@@ -492,7 +527,7 @@ mod session_tests {
#[test]
fn insert_then_remove_clears_entry() {
session::clear();
let h = session::insert(Zeroizing::new([0x11u8; 32]));
let h = session::insert(Zeroizing::new([0x11u8; 32]), Zeroizing::new([0u8; 32]));
assert_ne!(h, 0);
assert!(session::remove(h));
assert!(!session::remove(h)); // second remove false
@@ -501,7 +536,7 @@ mod session_tests {
#[test]
fn with_yields_key_only_while_session_lives() {
session::clear();
let h = session::insert(Zeroizing::new([0x22u8; 32]));
let h = session::insert(Zeroizing::new([0x22u8; 32]), Zeroizing::new([0u8; 32]));
let byte = session::with(h, |k| k[0]);
assert_eq!(byte, Some(0x22));
session::remove(h);
@@ -513,7 +548,7 @@ mod session_tests {
fn manifest_round_trip_via_handle() {
use relicario_core::{Manifest, decrypt_manifest};
session::clear();
let h = session::insert(Zeroizing::new([0x55u8; 32]));
let h = session::insert(Zeroizing::new([0x55u8; 32]), Zeroizing::new([0u8; 32]));
let handle = SessionHandle(h);
let key = Zeroizing::new([0x55u8; 32]);
let empty = Manifest::new();

View File

@@ -6,12 +6,17 @@ use std::cell::RefCell;
use std::collections::HashMap;
use zeroize::Zeroizing;
pub struct SessionData {
pub master_key: Zeroizing<[u8; 32]>,
pub image_secret: Zeroizing<[u8; 32]>,
}
thread_local! {
static SESSIONS: RefCell<HashMap<u32, Zeroizing<[u8; 32]>>> = RefCell::new(HashMap::new());
static SESSIONS: RefCell<HashMap<u32, SessionData>> = RefCell::new(HashMap::new());
static NEXT_HANDLE: RefCell<u32> = const { RefCell::new(1) };
}
pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 {
pub fn insert(master_key: Zeroizing<[u8; 32]>, image_secret: Zeroizing<[u8; 32]>) -> u32 {
let handle = NEXT_HANDLE.with(|n| {
let mut n = n.borrow_mut();
let h = *n;
@@ -19,15 +24,26 @@ pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 {
if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle
h
});
SESSIONS.with(|s| { s.borrow_mut().insert(handle, key); });
SESSIONS.with(|s| {
s.borrow_mut().insert(handle, SessionData { master_key, image_secret });
});
handle
}
/// Access the master key for a handle. Preserves original `with` signature for all existing callers.
pub fn with<F, R>(handle: u32, f: F) -> Option<R>
where
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
{
SESSIONS.with(|s| s.borrow().get(&handle).map(f))
SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.master_key)))
}
/// Access the image_secret for a handle (used by recovery QR).
pub fn with_image_secret<F, R>(handle: u32, f: F) -> Option<R>
where
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
{
SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.image_secret)))
}
pub fn remove(handle: u32) -> bool {

View File

@@ -1,22 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
vi.stubGlobal('chrome', {
storage: {
local: {
get: vi.fn((_keys: unknown, cb: (r: Record<string, unknown>) => void) => cb({})),
set: vi.fn((_data: unknown, cb?: () => void) => cb?.()),
},
},
});
import * as settingsMod from '../settings';
describe('settings module contract', () => {
it('exports renderSettings as a function', () => {
expect(typeof settingsMod.renderSettings).toBe('function');
});
it('exports teardownSettings as a function', () => {
expect(typeof settingsMod.teardownSettings).toBe('function');
});
});

View File

@@ -1,17 +1,329 @@
// extension/src/popup/components/settings-security.ts
// Stub — real implementation provided by Stream C (DEV-C).
/// Security settings section — three-state Recovery QR + Trusted Devices panel.
///
/// Exported contract:
/// renderSecuritySection(container, sessionHandle): renders into `container`
/// teardownSecuritySection(): removes any open QR modal
import { sendMessage, escapeHtml } from '../../shared/state';
import type { Device } from '../../shared/types';
// --- Relative time helper ---
function relativeTime(unixSec: number): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - unixSec;
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`;
return `${Math.floor(diff / 2592000)}mo ago`;
}
// --- Modal helpers ---
const MODAL_ID = 'relicario-qr-modal';
function removeModal(): void {
document.getElementById(MODAL_ID)?.remove();
}
function showQrModal(svgContent: string): void {
removeModal();
const overlay = document.createElement('div');
overlay.id = MODAL_ID;
overlay.style.cssText = [
'position:fixed', 'inset:0', 'z-index:9999',
'background:rgba(0,0,0,0.85)',
'display:flex', 'flex-direction:column',
'align-items:center', 'justify-content:center',
'padding:16px', 'box-sizing:border-box',
].join(';');
overlay.innerHTML = `
<div style="
background:#161b22; border:1px solid #30363d; border-radius:8px;
padding:16px; max-width:340px; width:100%; text-align:center;
">
<div style="font-size:13px; font-weight:600; margin-bottom:8px; color:#e6edf3;">
Recovery QR
</div>
<div style="font-size:11px; color:#8b949e; margin-bottom:12px;">
Print or store this QR. It encodes your reference image secret,
protected by your passphrase.
</div>
<div id="relicario-qr-svg" style="
background:#fff; border-radius:4px; padding:8px;
display:inline-block; max-width:280px; width:100%;
">
${svgContent}
</div>
<div style="display:flex; gap:8px; margin-top:12px; justify-content:center;">
<button id="relicario-qr-print" class="btn btn-primary" style="font-size:12px;">
Print
</button>
<button id="relicario-qr-done" class="btn" style="font-size:12px;">
Done
</button>
</div>
</div>
`;
document.body.appendChild(overlay);
document.getElementById('relicario-qr-done')?.addEventListener('click', removeModal);
document.getElementById('relicario-qr-print')?.addEventListener('click', () => {
const win = window.open('', '_blank', 'width=400,height=500');
if (!win) return;
win.document.write(`
<!DOCTYPE html>
<html><head><title>Recovery QR</title>
<style>
body { margin: 0; display: flex; flex-direction: column; align-items: center;
font-family: sans-serif; padding: 24px; }
h2 { font-size: 16px; margin-bottom: 8px; }
p { font-size: 12px; color: #555; margin-bottom: 16px; text-align: center; }
svg { max-width: 280px; width: 100%; }
</style></head><body>
<h2>Relicario Recovery QR</h2>
<p>Scan with the Relicario app to recover your reference image secret.<br>
Keep this page in a safe physical location.</p>
${svgContent}
<script>window.onload = () => { window.print(); window.close(); }<\/script>
</body></html>
`);
win.document.close();
});
// Close on backdrop click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) removeModal();
});
}
// --- Main render ---
export async function renderSecuritySection(
container: HTMLElement,
_sessionHandle: number | null,
sessionHandle: number | null,
): Promise<void> {
container.innerHTML = `
<div class="settings-section-placeholder">
<span class="muted">Security settings — loading…</span>
// Read timestamp from device-local storage (never the QR payload itself)
const stored = await chrome.storage.local.get(['recovery_qr_generated_at']);
const generatedAt: number | null = (stored.recovery_qr_generated_at as number) ?? null;
const isUnlocked = sessionHandle !== null;
// --- QR status section ---
let qrStatusHtml: string;
if (generatedAt === null) {
qrStatusHtml = `
<div style="
display:flex; align-items:flex-start; gap:10px;
background:#2d1f00; border:1px solid #7c5719; border-radius:6px;
padding:10px; margin-bottom:12px;
">
<span style="font-size:16px;">⚠</span>
<div style="flex:1; font-size:12px;">
<div style="color:#e3a726; font-weight:600; margin-bottom:2px;">
No recovery QR generated
</div>
<div style="color:#8b949e;">
If you lose access to your reference image, you will be locked out permanently.
</div>
</div>
</div>
<button
class="btn btn-primary"
id="sec-generate-qr"
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
style="width:100%; font-size:12px; margin-bottom:4px;"
>
Generate recovery QR…
</button>
`;
} else {
qrStatusHtml = `
<div style="
display:flex; align-items:flex-start; gap:10px;
background:#0a2a1a; border:1px solid #238636; border-radius:6px;
padding:10px; margin-bottom:12px;
">
<span style="font-size:16px;">✓</span>
<div style="flex:1; font-size:12px;">
<div style="color:#3fb950; font-weight:600; margin-bottom:2px;">
Recovery QR set up
</div>
<div style="color:#8b949e;">
Generated ${relativeTime(generatedAt)}. Store the printout in a safe place.
</div>
</div>
</div>
<div style="display:flex; gap:8px; margin-bottom:4px;">
<button
class="btn"
id="sec-show-qr"
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
style="flex:1; font-size:12px;"
>
Show / print QR…
</button>
<button
class="btn"
id="sec-regenerate-qr"
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
style="flex:1; font-size:12px;"
>
Regenerate…
</button>
</div>
`;
}
export function teardownSecuritySection(): void {
// no-op in stub
// --- Devices section ---
const devicesResp = await sendMessage({ type: 'list_devices' });
let devicesHtml: string;
if (!devicesResp.ok) {
devicesHtml = `<p class="muted" style="font-size:12px;">Could not load devices.</p>`;
} else {
const devices = (devicesResp.data as { devices: Device[] }).devices;
const currentDeviceNameStored = await chrome.storage.local.get(['device_name']);
const currentDeviceName: string | undefined = currentDeviceNameStored.device_name as string | undefined;
if (devices.length === 0) {
devicesHtml = `<p class="muted" style="font-size:12px; text-align:center; margin-top:8px;">No devices registered.</p>`;
} else {
devicesHtml = devices.map((d) => {
const isCurrent = d.name === currentDeviceName;
return `
<div class="device-row" style="display:flex; align-items:center; justify-content:space-between; padding:6px 0; border-bottom:1px solid #21262d;">
<div style="flex:1; min-width:0;">
<div style="font-size:12px; font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
${escapeHtml(d.name)}${isCurrent ? ' <span style="color:#8b949e; font-weight:400; font-size:11px;">(this device)</span>' : ''}
</div>
<div style="font-size:11px; color:#8b949e;">added ${relativeTime(d.added_at)}</div>
</div>
${isCurrent ? '' : `
<button
class="btn sec-revoke-btn"
data-device-name="${escapeHtml(d.name)}"
style="font-size:11px; margin-left:8px; flex-shrink:0;"
>revoke</button>
`}
</div>
`;
}).join('');
}
}
// --- Assemble ---
container.innerHTML = `
<div class="settings-section" style="margin-top:0;">
<div class="settings-section__title" style="font-size:12px; color:#8b949e; margin-bottom:8px; text-transform:uppercase; letter-spacing:0.05em;">
Recovery QR
</div>
${qrStatusHtml}
<div id="sec-qr-error" style="font-size:11px; color:#f85149; margin-top:4px; min-height:14px;"></div>
</div>
<div class="settings-section" style="margin-top:16px;">
<div class="settings-section__title" style="font-size:12px; color:#8b949e; margin-bottom:8px; text-transform:uppercase; letter-spacing:0.05em;">
Trusted Devices
</div>
<div id="sec-devices-list">
${devicesHtml}
</div>
</div>
`;
// --- Wire handlers ---
const setQrError = (msg: string): void => {
const el = document.getElementById('sec-qr-error');
if (el) el.textContent = msg;
};
async function doGenerateQr(isRegen: boolean): Promise<void> {
const passphrase = prompt(
isRegen
? 'Enter your vault passphrase to regenerate the recovery QR:'
: 'Enter your vault passphrase to generate the recovery QR:',
);
if (!passphrase) return;
const btn = document.getElementById(isRegen ? 'sec-regenerate-qr' : 'sec-generate-qr') as HTMLButtonElement | null;
if (btn) { btn.disabled = true; btn.textContent = '…'; }
const resp = await sendMessage({ type: 'generate_recovery_qr', passphrase });
if (!resp.ok) {
setQrError(`Failed: ${resp.error}`);
if (btn) { btn.disabled = false; btn.textContent = isRegen ? 'Regenerate…' : 'Generate recovery QR…'; }
return;
}
const svg = (resp.data as { svg: string }).svg;
const now = Math.floor(Date.now() / 1000);
// Store only the timestamp, NEVER the QR payload
await chrome.storage.local.set({ recovery_qr_generated_at: now });
showQrModal(svg);
// Re-render to reflect new state (timestamp now exists)
await renderSecuritySection(container, sessionHandle);
}
document.getElementById('sec-generate-qr')?.addEventListener('click', () => {
void doGenerateQr(false);
});
document.getElementById('sec-regenerate-qr')?.addEventListener('click', () => {
void doGenerateQr(true);
});
document.getElementById('sec-show-qr')?.addEventListener('click', async () => {
const passphrase = prompt('Enter your vault passphrase to view the recovery QR:');
if (!passphrase) return;
const btn = document.getElementById('sec-show-qr') as HTMLButtonElement | null;
if (btn) { btn.disabled = true; btn.textContent = '…'; }
const resp = await sendMessage({ type: 'generate_recovery_qr', passphrase });
if (!resp.ok) {
setQrError(`Failed: ${resp.error}`);
if (btn) { btn.disabled = false; btn.textContent = 'Show / print QR…'; }
return;
}
if (btn) { btn.disabled = false; btn.textContent = 'Show / print QR…'; }
const svg = (resp.data as { svg: string }).svg;
showQrModal(svg);
});
// Revoke buttons
container.querySelectorAll<HTMLButtonElement>('.sec-revoke-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const name = btn.dataset.deviceName;
if (!name) return;
if (!confirm(`Revoke "${name}"? This device will no longer be authorized.`)) return;
btn.disabled = true;
btn.textContent = '…';
const result = await sendMessage({ type: 'revoke_device', name });
if (result.ok) {
await sendMessage({ type: 'sync' });
// Re-render to refresh device list
await renderSecuritySection(container, sessionHandle);
} else {
btn.disabled = false;
btn.textContent = 'revoke';
setQrError(`Revoke failed: ${result.error}`);
}
});
});
}
export function teardownSecuritySection(): void {
removeModal();
}

View File

@@ -1,111 +1,18 @@
import { sendMessage, escapeHtml, openVaultTab } from '../../shared/state';
import type { VaultSettings, DeviceSettings, TrashRetention, HistoryRetention } from '../../shared/types';
import type { ColorScheme } from '../../shared/color-scheme';
/// Settings view — capture toggle, prompt style, and blacklist management.
import { sendMessage, navigate, escapeHtml } from '../../shared/state';
import type { DeviceSettings } from '../../shared/types';
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';
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel';
import { renderSecuritySection, teardownSecuritySection } from './settings-security';
type SettingsSection =
| 'autofill'
| 'display'
| 'security'
| 'generator'
| 'retention'
| 'backup'
| 'import';
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>';
const NAV_ITEMS: Array<{ id: SettingsSection; icon: string; label: string; group: 'device' | 'vault' }> = [
{ id: 'autofill', icon: '⊙', label: 'Autofill', group: 'device' },
{ id: 'display', icon: '◈', label: 'Display', group: 'device' },
{ id: 'security', icon: '◉', label: 'Security', group: 'vault' },
{ id: 'generator', icon: '↻', label: 'Generator', group: 'vault' },
{ id: 'retention', icon: '▦', label: 'Retention', group: 'vault' },
{ id: 'backup', icon: '⤓', label: 'Backup', group: 'vault' },
{ id: 'import', icon: '≡', label: 'Import', group: 'vault' },
];
let activeSection: SettingsSection = 'autofill';
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
let pendingVaultSettings: VaultSettings | null = null;
let sessionHandle: number | null = null;
export async function renderSettings(container: HTMLElement): Promise<void> {
container.innerHTML = `
<div class="settings-layout">
<nav class="settings-nav" id="settings-nav">
<div class="settings-nav__group-label">Device</div>
${NAV_ITEMS.filter(n => n.group === 'device').map(navItemHtml).join('')}
<div class="settings-nav__group-label">Vault</div>
${NAV_ITEMS.filter(n => n.group === 'vault').map(navItemHtml).join('')}
</nav>
<div class="settings-content" id="settings-content"></div>
</div>
`;
const unlockedResp = await sendMessage({ type: 'is_unlocked' });
sessionHandle = (unlockedResp.ok && unlockedResp.data && (unlockedResp.data as { unlocked: boolean }).unlocked) ? 1 : null;
wireNav();
await renderSection(activeSection);
}
export function teardownSettings(): void {
closeGeneratorPanel();
teardownSecuritySection();
if (activeKeyHandler) {
document.removeEventListener('keydown', activeKeyHandler);
activeKeyHandler = null;
}
pendingVaultSettings = null;
sessionHandle = null;
}
function navItemHtml(item: (typeof NAV_ITEMS)[0]): string {
const active = item.id === activeSection ? ' settings-nav__item--active' : '';
return `
<button class="settings-nav__item${active}" data-section="${item.id}">
<span class="settings-nav__icon" aria-hidden="true">${item.icon}</span>
<span class="settings-nav__label">${escapeHtml(item.label)}</span>
</button>
`;
}
function wireNav(): void {
document.getElementById('settings-nav')?.querySelectorAll<HTMLButtonElement>('[data-section]')
.forEach((btn) => {
btn.addEventListener('click', async () => {
teardownSecuritySection();
closeGeneratorPanel();
activeSection = btn.dataset.section as SettingsSection;
document.querySelectorAll('.settings-nav__item').forEach(b => b.classList.remove('settings-nav__item--active'));
btn.classList.add('settings-nav__item--active');
await renderSection(activeSection);
});
});
}
async function renderSection(section: SettingsSection): Promise<void> {
const content = document.getElementById('settings-content');
if (!content) return;
switch (section) {
case 'autofill': return renderAutofillSection(content);
case 'display': return renderDisplaySection(content);
case 'security': return renderSecuritySection(content, sessionHandle);
case 'generator': return renderGeneratorSection(content);
case 'retention': return renderRetentionSection(content);
case 'backup': return renderBackupSection(content);
case 'import': return renderImportSection(content);
}
}
// --- Section stubs (filled in by Tasks 3-9) ---
async function renderAutofillSection(content: HTMLElement): Promise<void> {
// Load settings and blacklist in parallel
const [settingsResp, blacklistResp] = await Promise.all([
sendMessage({ type: 'get_settings' }),
sendMessage({ type: 'get_blacklist' }),
@@ -119,314 +26,166 @@ async function renderAutofillSection(content: HTMLElement): Promise<void> {
? (blacklistResp.data as { blacklist: string[] }).blacklist
: [];
content.innerHTML = `
<h3 class="settings-section-title">Capture</h3>
<div class="setting-row">
<div class="setting-row__info">
<div class="setting-row__title">Auto-detect logins</div>
<div class="setting-row__desc">Show a prompt when a login form is detected.</div>
const blacklistHtml = blacklist.length > 0
? blacklist.map((h) => `
<div style="display:flex; align-items:center; justify-content:space-between; padding:4px 0; border-bottom:1px solid #21262d;">
<span style="font-size:12px; overflow:hidden; text-overflow:ellipsis;">${escapeHtml(h)}</span>
<button class="relicario-remove-bl" data-hostname="${escapeHtml(h)}" style="
background:transparent; color:#ab2b20; border:none; cursor:pointer;
font-size:11px; padding:2px 6px;
">remove</button>
</div>
<div class="setting-row__control">
`).join('')
: '<p class="muted" style="font-size:12px;">no blacklisted sites</p>';
app.innerHTML = `
<div class="pad" style="padding-top:12px;">
<div style="display:flex; align-items:center; margin-bottom:16px;">
<button id="settings-back" class="btn" style="font-size:11px; margin-right:8px;">&larr;</button>
<span style="font-size:14px; font-weight:600;">settings</span>
</div>
<div style="margin-bottom:16px;">
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px;">
<input type="checkbox" id="capture-enabled" ${settings.captureEnabled ? 'checked' : ''}>
auto-detect logins
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-row__info">
<div class="setting-row__title">Prompt style</div>
<div class="setting-row__desc">How to prompt when a login is detected.</div>
</div>
<div class="setting-row__control" style="display:flex; gap:6px;">
<button class="btn ${settings.captureStyle === 'bar' ? 'btn-active' : ''}" id="style-bar" style="font-size:11px;">bar</button>
<button class="btn ${settings.captureStyle === 'toast' ? 'btn-active' : ''}" id="style-toast" style="font-size:11px;">toast</button>
<div style="margin-bottom:16px;">
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">prompt style</div>
<div style="display:flex; gap:8px;">
<button id="style-bar" class="btn" style="font-size:11px; ${settings.captureStyle === 'bar' ? 'background:#7c5719; color:#fff;' : ''}">bar</button>
<button id="style-toast" class="btn" style="font-size:11px; ${settings.captureStyle === 'toast' ? 'background:#7c5719; color:#fff;' : ''}">toast</button>
</div>
</div>
<h3 class="settings-section-title" style="margin-top:20px;">Blocked sites</h3>
<div style="margin-bottom:16px;">
<button class="btn" id="trash-btn" style="width:100%;margin-bottom:8px;">${GLYPH_TRASH} trash</button>
<button class="btn" id="devices-btn" style="width:100%;margin-bottom:8px;">${GLYPH_DEVICES} devices</button>
<button class="btn" id="sync-now-btn" style="width:100%;margin-bottom:8px;">📤 Sync now</button>
<div id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div>
</div>
<div style="margin-bottom:16px;" id="display-section-container">
</div>
<div>
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">blacklisted sites</div>
<div id="blacklist-container">
${blacklist.length > 0
? blacklist.map((h) => `
<div class="setting-row">
<div class="setting-row__info">
<div class="setting-row__title">${escapeHtml(h)}</div>
${blacklistHtml}
</div>
<button class="btn remove-bl" data-hostname="${escapeHtml(h)}" style="font-size:11px;">remove</button>
</div>
`).join('')
: '<p class="muted" style="font-size:12px; padding:8px 0;">No blocked sites.</p>'}
</div>
`;
document.getElementById('capture-enabled')?.addEventListener('change', async (e) => {
const enabled = (e.target as HTMLInputElement).checked;
await sendMessage({ type: 'update_settings', settings: { captureEnabled: enabled } });
// Back button
document.getElementById('settings-back')?.addEventListener('click', () => {
navigate('locked');
});
// Navigation buttons
document.getElementById('trash-btn')?.addEventListener('click', () => navigate('trash'));
document.getElementById('devices-btn')?.addEventListener('click', () => navigate('devices'));
// Sync now button
document.getElementById('sync-now-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('sync-now-btn') as HTMLButtonElement | null;
const status = document.getElementById('sync-status');
if (!btn || !status) return;
btn.disabled = true;
status.textContent = 'syncing...';
const result = await sendMessage({ type: 'sync' });
btn.disabled = false;
status.textContent = result.ok ? 'synced ✓' : `sync failed: ${result.error}`;
});
// Capture enabled toggle
document.getElementById('capture-enabled')?.addEventListener('change', async (e) => {
const checked = (e.target as HTMLInputElement).checked;
await sendMessage({ type: 'update_settings', settings: { captureEnabled: checked } });
});
// Style buttons
document.getElementById('style-bar')?.addEventListener('click', async () => {
await sendMessage({ type: 'update_settings', settings: { captureStyle: 'bar' } });
renderAutofillSection(content);
renderSettings(app);
});
document.getElementById('style-toast')?.addEventListener('click', async () => {
await sendMessage({ type: 'update_settings', settings: { captureStyle: 'toast' } });
renderAutofillSection(content);
renderSettings(app);
});
content.querySelectorAll<HTMLButtonElement>('.remove-bl').forEach((btn) => {
// Blacklist remove buttons
document.querySelectorAll('.relicario-remove-bl').forEach((btn) => {
btn.addEventListener('click', async () => {
const host = btn.dataset.hostname;
if (!host) return;
await sendMessage({ type: 'remove_blacklist', hostname: host });
renderAutofillSection(content);
const hostname = (btn as HTMLElement).dataset.hostname;
if (hostname) {
await sendMessage({ type: 'remove_blacklist', hostname });
renderSettings(app);
}
});
});
// Render Display section after the rest of the DOM is ready
await renderDisplaySection();
}
async function renderDisplaySection(content: HTMLElement): Promise<void> {
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();
content.innerHTML = `
<h3 class="settings-section-title">Password coloring</h3>
<div class="setting-row">
<div class="setting-row__info">
<div class="setting-row__title">Digit color</div>
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 class="setting-row__control">
<input type="color" id="digit-color" value="${escapeHtml(scheme.digit_color)}">
<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>
<div class="setting-row">
<div class="setting-row__info">
<div class="setting-row__title">Symbol color</div>
</div>
<div class="setting-row__control">
<input type="color" id="symbol-color" value="${escapeHtml(scheme.symbol_color)}">
</div>
</div>
<div class="setting-row">
<div class="setting-row__info">
<div class="setting-row__title">Preview</div>
</div>
<div class="setting-row__control">
<span id="color-preview" style="font-family:monospace; font-size:13px;"></span>
</div>
</div>
<div style="margin-top:12px;">
<button class="btn" id="reset-colors" style="font-size:11px;">Reset defaults</button>
<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>
`;
function refreshPreview(s: ColorScheme): void {
const preview = document.getElementById('color-preview');
if (!preview) return;
preview.style.setProperty('--relicario-pwd-digit-color', s.digit_color);
preview.style.setProperty('--relicario-pwd-symbol-color', s.symbol_color);
preview.innerHTML = '';
preview.appendChild(colorizePassword('Abc123!@#'));
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);
}
refreshPreview(scheme);
digitInput.addEventListener('change', () => void onColorChange());
symbolInput.addEventListener('change', () => void onColorChange());
document.getElementById('digit-color')?.addEventListener('change', async (e) => {
const color = (e.target as HTMLInputElement).value;
const current = await loadColorScheme();
await saveColorScheme({ ...current, digit_color: color });
refreshPreview({ ...current, digit_color: color });
});
document.getElementById('symbol-color')?.addEventListener('change', async (e) => {
const color = (e.target as HTMLInputElement).value;
const current = await loadColorScheme();
await saveColorScheme({ ...current, symbol_color: color });
refreshPreview({ ...current, symbol_color: color });
});
document.getElementById('reset-colors')?.addEventListener('click', async () => {
document.getElementById('display-reset')?.addEventListener('click', async () => {
await resetColorScheme();
renderDisplaySection(content);
digitInput.value = DEFAULT_DIGIT_COLOR;
symbolInput.value = DEFAULT_SYMBOL_COLOR;
updateSwatch(swatch, DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR);
});
}
async function renderGeneratorSection(content: HTMLElement): Promise<void> {
content.innerHTML = '<p class="muted" style="padding:20px;font-size:12px;">Loading…</p>';
const resp = await sendMessage({ type: 'get_vault_settings' });
if (!resp.ok) {
const errorMsg = (resp as { ok: false; error: string }).error;
content.innerHTML = `<p class="muted" style="padding:20px;">Failed to load: ${escapeHtml(errorMsg)}</p>`;
return;
}
const settings = (resp.data as { settings: VaultSettings }).settings;
content.innerHTML = `
<h3 class="settings-section-title">Generator defaults</h3>
<div class="setting-row">
<div class="setting-row__info">
<div class="setting-row__title">Configure generator</div>
<div class="setting-row__desc">Password length, character classes, passphrase word count.</div>
</div>
<div class="setting-row__control">
<button class="btn" id="open-generator-panel" style="font-size:11px;">Configure ▸</button>
</div>
</div>
`;
document.getElementById('open-generator-panel')?.addEventListener('click', (e) => {
const trigger = e.currentTarget as HTMLElement;
if (isGeneratorPanelOpen()) {
closeGeneratorPanel();
return;
}
openGeneratorPanel({
parent: content,
trigger,
initial: settings.generator_defaults,
context: 'configure-defaults',
});
});
}
async function renderRetentionSection(content: HTMLElement): Promise<void> {
content.innerHTML = '<p class="muted" style="padding:20px;font-size:12px;">Loading…</p>';
const resp = await sendMessage({ type: 'get_vault_settings' });
if (!resp.ok) {
content.innerHTML = `<p class="muted" style="padding:20px;">Failed to load: ${escapeHtml(resp.error ?? 'unknown')}</p>`;
return;
}
const settings = (resp.data as { settings: VaultSettings }).settings;
pendingVaultSettings = { ...settings };
content.innerHTML = `
<h3 class="settings-section-title">Trash retention</h3>
<div class="setting-row">
<div class="setting-row__info">
<div class="setting-row__title">Keep deleted items for</div>
<div class="setting-row__desc">Items in trash older than this are permanently deleted on the next sync.</div>
</div>
<div class="setting-row__control">
<select id="trash-retention" style="font-size:12px;">
<option value="days:7">7 days</option>
<option value="days:30">30 days</option>
<option value="days:90">90 days</option>
<option value="forever">Forever</option>
</select>
</div>
</div>
<h3 class="settings-section-title" style="margin-top:20px;">Field history retention</h3>
<div class="setting-row">
<div class="setting-row__info">
<div class="setting-row__title">Keep password history for</div>
<div class="setting-row__desc">History entries older than this are pruned on save.</div>
</div>
<div class="setting-row__control">
<select id="history-retention" style="font-size:12px;">
<option value="last_n:5">Last 5</option>
<option value="last_n:10">Last 10</option>
<option value="days:90">90 days</option>
<option value="days:365">1 year</option>
<option value="forever">Forever</option>
</select>
</div>
</div>
<div style="margin-top:12px;">
<button class="btn btn-primary" id="save-retention" style="font-size:11px;">Save retention settings</button>
</div>
`;
// Set current select values
(document.getElementById('trash-retention') as HTMLSelectElement).value =
trashRetentionToValue(settings.trash_retention);
(document.getElementById('history-retention') as HTMLSelectElement).value =
historyRetentionToValue(settings.field_history_retention);
document.getElementById('trash-retention')?.addEventListener('change', (e) => {
if (pendingVaultSettings) {
pendingVaultSettings.trash_retention = valueToTrashRetention((e.target as HTMLSelectElement).value);
}
});
document.getElementById('history-retention')?.addEventListener('change', (e) => {
if (pendingVaultSettings) {
pendingVaultSettings.field_history_retention = valueToHistoryRetention((e.target as HTMLSelectElement).value);
}
});
document.getElementById('save-retention')?.addEventListener('click', async () => {
if (!pendingVaultSettings) return;
const r = await sendMessage({ type: 'update_vault_settings', settings: pendingVaultSettings });
if (!r.ok) alert(`Save failed: ${r.error}`);
});
}
function trashRetentionToValue(r: TrashRetention): string {
if (r.kind === 'forever') return 'forever';
return `days:${r.value}`;
}
function valueToTrashRetention(v: string): TrashRetention {
if (v === 'forever') return { kind: 'forever' };
const m = /^days:(\d+)$/.exec(v);
if (m) return { kind: 'days', value: Number(m[1]) };
return { kind: 'forever' };
}
function historyRetentionToValue(r: HistoryRetention): string {
if (r.kind === 'forever') return 'forever';
if (r.kind === 'last_n') return `last_n:${r.value}`;
return `days:${r.value}`;
}
function valueToHistoryRetention(v: string): HistoryRetention {
if (v === 'forever') return { kind: 'forever' };
const mLast = /^last_n:(\d+)$/.exec(v);
if (mLast) return { kind: 'last_n', value: Number(mLast[1]) };
const mDays = /^days:(\d+)$/.exec(v);
if (mDays) return { kind: 'days', value: Number(mDays[1]) };
return { kind: 'forever' };
}
function renderBackupSection(content: HTMLElement): void {
content.innerHTML = `
<h3 class="settings-section-title">Backup &amp; restore</h3>
<div class="setting-row">
<div class="setting-row__info">
<div class="setting-row__title">Export &amp; restore backup</div>
<div class="setting-row__desc">Download an encrypted backup or restore from a file. Opens in the vault tab.</div>
</div>
<div class="setting-row__control">
<button class="btn" id="open-backup-tab" style="font-size:11px;">Open backup ▸</button>
</div>
</div>
`;
document.getElementById('open-backup-tab')?.addEventListener('click', () => openVaultTab('backup'));
}
function renderImportSection(content: HTMLElement): void {
content.innerHTML = `
<h3 class="settings-section-title">Import</h3>
<div class="setting-row">
<div class="setting-row__info">
<div class="setting-row__title">Import from LastPass</div>
<div class="setting-row__desc">Import a LastPass CSV export. Opens in the vault tab for review before committing.</div>
</div>
<div class="setting-row__control">
<button class="btn" id="open-import-tab" style="font-size:11px;">Open import ▸</button>
</div>
</div>
`;
document.getElementById('open-import-tab')?.addEventListener('click', () => openVaultTab('import'));
}
export { renderAutofillSection, renderDisplaySection, renderGeneratorSection,
renderRetentionSection, renderBackupSection, renderImportSection };
// Suppress unused-import warnings — these are used by Tasks 3-9
void sendMessage;
void loadColorScheme;
void saveColorScheme;
void resetColorScheme;
void DEFAULT_DIGIT_COLOR;
void DEFAULT_SYMBOL_COLOR;
void colorizePassword;
void openGeneratorPanel;
void pendingVaultSettings;
void activeKeyHandler;

View File

@@ -11,7 +11,7 @@ import { renderUnlock } from './components/unlock';
import { renderItemList } from './components/item-list';
import { renderItemDetail } from './components/item-detail';
import { renderItemForm } from './components/item-form';
import { renderSettings, teardownSettings } from './components/settings';
import { renderSettings } from './components/settings';
import { renderVaultSettings } from './components/settings-vault';
import { renderTrash } from './components/trash';
import { renderDevices } from './components/devices';
@@ -178,7 +178,6 @@ function render(): void {
teardownTrash();
teardownDevices();
teardownFieldHistory();
teardownSettings();
switch (currentState.view) {
case 'locked':

View File

@@ -424,6 +424,41 @@ textarea {
background: #aa812a;
}
/* Setup wizard — Style C progress track */
.setup-progress-track {
display: flex;
gap: 4px;
width: 100%;
max-width: 560px;
margin: 8px auto 16px;
}
.setup-progress-segment {
flex: 1;
height: 4px;
border-radius: 2px;
}
.setup-progress-segment--completed { background: var(--success, #238636); }
.setup-progress-segment--active { background: var(--gold, #b8860b); }
.setup-progress-segment--pending { background: var(--border, #30363d); }
/* Setup wizard — Recovery QR banner */
.recovery-qr-banner {
padding: 12px 16px;
background: var(--bg-elevated, #161b22);
border: 1px solid var(--gold, #b8860b);
border-radius: 8px;
}
.recovery-qr-banner__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.recovery-qr-banner__actions {
display: flex;
gap: 8px;
}
/* Spinner */
.spinner {
display: inline-block;
@@ -1573,92 +1608,3 @@ textarea {
margin-top: 8px;
background: var(--bg-input);
}
/* === Settings layout === */
.settings-layout {
display: flex;
height: 100%;
overflow: hidden;
}
.settings-nav {
width: 148px;
min-width: 148px;
border-right: 1px solid var(--border, #30363d);
padding: 12px 0;
overflow-y: auto;
flex-shrink: 0;
}
.settings-nav__group-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted, #8b949e);
padding: 8px 12px 4px;
}
.settings-nav__item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 12px;
background: transparent;
border: none;
cursor: pointer;
font-size: 13px;
color: inherit;
text-align: left;
}
.settings-nav__item:hover { background: var(--bg-hover, #161b22); }
.settings-nav__item--active { background: var(--bg-selected, #1c2d41); }
.settings-nav__icon { font-size: 14px; flex-shrink: 0; }
.settings-content {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
min-width: 0;
}
.setting-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid var(--border-subtle, #21262d);
}
.setting-row:last-child { border-bottom: none; }
.setting-row__info { flex: 1; }
.setting-row__title { font-size: 13px; font-weight: 500; }
.setting-row__desc { font-size: 11px; color: var(--text-muted, #8b949e); margin-top: 2px; }
.setting-row__control { flex-shrink: 0; }
.settings-section-title {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted, #8b949e);
margin: 0 0 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border, #30363d);
}
.setting-card {
padding: 12px 16px;
border-radius: 6px;
border: 1px solid var(--border, #30363d);
margin-bottom: 12px;
}
.setting-card--ok { border-color: var(--success, #238636); background: rgba(35, 134, 54, 0.06); }
.setting-card--warn { border-color: var(--gold, #b8860b); background: rgba(184, 134, 11, 0.06); }
.setting-card__status { font-size: 13px; margin-bottom: 8px; }
.setting-card__actions { display: flex; gap: 8px; }

View File

@@ -574,6 +574,26 @@ export async function handle(
}
}
case 'generate_recovery_qr': {
const handle = session.getCurrent();
if (!handle) return { ok: false, error: 'vault_locked' };
try {
const svg: string = state.wasm.wasm_generate_recovery_qr(handle, msg.passphrase);
return { ok: true, data: { svg } };
} catch (e) {
return { ok: false, error: (e as Error).message };
}
}
case 'unwrap_recovery_qr': {
try {
const imageSecretBytes: Uint8Array = state.wasm.wasm_unwrap_recovery_qr(msg.payload_b64, msg.passphrase);
return { ok: true, data: { image_secret: Array.from(imageSecretBytes) } };
} catch (e) {
return { ok: false, error: (e as Error).message };
}
}
case 'import_lastpass_commit': {
const handle = session.getCurrent();
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };

View File

@@ -93,6 +93,17 @@ const state: WizardState = {
deviceName: '',
};
// --- Progress track ---
const SETUP_STEP_NAMES = ['mode', 'host', 'connection', 'vault', 'device', 'done'];
function renderProgressTrack(current: number): string {
return `<div class="setup-progress-track">${SETUP_STEP_NAMES.map((_, i) => {
const cls = i < current ? 'completed' : i === current ? 'active' : 'pending';
return `<div class="setup-progress-segment setup-progress-segment--${cls}" title="${SETUP_STEP_NAMES[i]}"></div>`;
}).join('')}</div>`;
}
// --- State-coupled helpers (pure helpers live in ./setup-helpers.ts) ---
/// Update just the meter DOM without a full re-render (so the input keeps
@@ -168,16 +179,7 @@ function render(): void {
const app = document.getElementById('app');
if (!app) return;
const progressHtml = `
<div class="progress-bar">
<div class="step ${state.step > 0 ? 'done' : state.step === 0 ? 'current' : ''}"></div>
<div class="step ${state.step > 1 ? 'done' : state.step === 1 ? 'current' : ''}"></div>
<div class="step ${state.step > 2 ? 'done' : state.step === 2 ? 'current' : ''}"></div>
<div class="step ${state.step > 3 ? 'done' : state.step === 3 ? 'current' : ''}"></div>
<div class="step ${state.step > 4 ? 'done' : state.step === 4 ? 'current' : ''}"></div>
<div class="step ${state.step > 5 ? 'done' : state.step === 5 ? 'current' : ''}"></div>
</div>
`;
const progressHtml = renderProgressTrack(state.step);
let stepHtml = '';
switch (state.step) {
@@ -224,6 +226,7 @@ function renderStep0(): string {
</p>
<div class="mode-cards">
<button class="mode-card glass ${isNew ? 'active' : ''}" data-mode="new">
<span class="mode-card__icon" style="font-size:28px;">◈</span>
<div class="mode-card-title">create new vault</div>
<p class="mode-card-blurb">
I'm setting up Relicario for the first time. This will create a fresh
@@ -231,6 +234,7 @@ function renderStep0(): string {
</p>
</button>
<button class="mode-card glass ${isAttach ? 'active' : ''}" data-mode="attach">
<span class="mode-card__icon" style="font-size:28px;">⌥</span>
<div class="mode-card-title">attach this device</div>
<p class="mode-card-blurb">
I already have a vault on another device. Connect this browser to it
@@ -981,6 +985,22 @@ function renderStep5(): string {
const configJson = JSON.stringify(config, null, 2);
const isAttach = state.mode === 'attach';
const qrBannerHtml = (!isAttach && state.verifiedHandle !== null) ? `
<div class="recovery-qr-banner" id="recovery-qr-banner" style="margin-bottom:16px;">
<div class="recovery-qr-banner__header">
<span style="font-size:20px;">◫</span>
<strong>Generate a recovery QR before you go</strong>
</div>
<p class="muted" style="font-size:12px;margin:4px 0 8px;">
If you lose your reference image, this QR lets you recover your vault. Print it and store it safely.
</p>
<div class="recovery-qr-banner__actions">
<button class="btn btn-primary" id="setup-gen-qr">Generate now</button>
<button class="btn" id="setup-skip-qr">Skip — I'll do this in Settings</button>
</div>
</div>
` : '';
return `
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<div class="success-box">
@@ -992,6 +1012,8 @@ function renderStep5(): string {
</p>
</div>
${qrBannerHtml}
${isAttach ? '' : `
<div class="form-group">
<label class="label">reference image</label>
@@ -1026,6 +1048,48 @@ function renderStep5(): string {
}
function attachStep5(): void {
document.getElementById('setup-gen-qr')?.addEventListener('click', async () => {
if (!state.verifiedHandle) return;
const btn = document.getElementById('setup-gen-qr') as HTMLButtonElement | null;
if (btn) { btn.disabled = true; btn.textContent = 'Generating…'; }
try {
const { sendMessage } = await import('../shared/state');
const resp = await sendMessage({
type: 'generate_recovery_qr',
sessionHandle: state.verifiedHandle.value,
passphrase: state.passphrase,
} as any) as any;
if (!resp.ok || !resp.data) throw new Error(resp.error ?? 'unknown error');
const svg = (resp.data as { svg: string }).svg;
await new Promise<void>((resolve) => {
chrome.storage.local.set({ recovery_qr_generated_at: Date.now() }, resolve);
});
const banner = document.getElementById('recovery-qr-banner');
if (banner) {
banner.innerHTML = `
<div style="text-align:center;">${svg}</div>
<p style="font-size:12px;color:var(--success,#238636);margin:8px 0 0;">
◉ Recovery QR generated — save or print this now.
</p>
<div style="margin-top:8px;">
<button class="btn" id="setup-qr-done">Done</button>
</div>
`;
document.getElementById('setup-qr-done')?.addEventListener('click', () => {
banner.style.display = 'none';
});
}
} catch (err) {
if (btn) { btn.disabled = false; btn.textContent = 'Generate now'; }
alert(`Failed to generate QR: ${err instanceof Error ? err.message : String(err)}`);
}
});
document.getElementById('setup-skip-qr')?.addEventListener('click', () => {
const banner = document.getElementById('recovery-qr-banner');
if (banner) banner.style.display = 'none';
});
document.getElementById('download-ref-btn')?.addEventListener('click', () => {
if (!state.referenceImageBytes) return;
const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' });

View File

@@ -61,7 +61,9 @@ export type PopupMessage =
}
| { type: 'parse_lastpass_csv'; bytes: ArrayBuffer }
| { type: 'import_lastpass_commit'; items: Item[] }
| { type: 'preview_totp_from_secret'; secret_b32: string };
| { type: 'preview_totp_from_secret'; secret_b32: string }
| { type: 'generate_recovery_qr'; passphrase: string }
| { type: 'unwrap_recovery_qr'; payload_b64: string; passphrase: string };
// --- Messages a content script may send ---
@@ -173,6 +175,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
'export_backup', 'restore_backup',
'parse_lastpass_csv', 'import_lastpass_commit',
'preview_totp_from_secret',
'generate_recovery_qr', 'unwrap_recovery_qr',
] as PopupMessage['type'][]);
export interface ExportBackupResponse extends Extract<Response, { ok: true }> {

View File

@@ -79,6 +79,9 @@ declare module 'relicario-wasm' {
export function clear_device(): void;
export function get_field_history(item_json: string): unknown;
export function wasm_generate_recovery_qr(handle: SessionHandle, passphrase: string): string;
export function wasm_unwrap_recovery_qr(payload_b64: string, passphrase: string): Uint8Array;
export default function init(module_or_path?: unknown): Promise<void>;
export function initSync(args: { module: WebAssembly.Module }): void;
}