diff --git a/crates/relicario-core/src/device.rs b/crates/relicario-core/src/device.rs index f2ce780..12dd42d 100644 --- a/crates/relicario-core/src/device.rs +++ b/crates/relicario-core/src/device.rs @@ -54,10 +54,20 @@ pub fn generate_keypair() -> Result<(Zeroizing, String)> { Ok((Zeroizing::new(private_pem.to_string()), public_line)) } -/// Sign data with an OpenSSH private key, returning base64 signature. -pub fn sign(private_key_openssh: &str, data: &[u8]) -> Result { - use base64::Engine; - +/// Extract the raw 32-byte ed25519 seed from an OpenSSH PEM private key. +/// +/// **Device-key form (verified against the producers):** `generate_keypair()` +/// emits the private key as an OpenSSH PEM blob — the multiline +/// `-----BEGIN OPENSSH PRIVATE KEY-----...` form produced by +/// `ssh_key::PrivateKey::to_openssh()` — NOT a base64-encoded raw-bytes string. +/// The org plan's `device_private_key_base64` parameter name was therefore +/// misleading; the design spec's `device_private_openssh` matches reality. The +/// extension SW holds this blob in WASM `DEVICE_STATE` and never exposes it to JS. +/// +/// The seed bytes are copied straight into a `Zeroizing<[u8; 32]>` — no +/// intermediate plain `[u8; 32]` of secret material is ever materialized — so +/// the seed is wiped on drop. +pub fn extract_ed25519_seed(private_key_openssh: &str) -> Result> { let private = PrivateKey::from_openssh(private_key_openssh) .map_err(|e| RelicarioError::DeviceKey(format!("parse private key: {e}")))?; @@ -67,11 +77,20 @@ pub fn sign(private_key_openssh: &str, data: &[u8]) -> Result { .ok_or_else(|| RelicarioError::DeviceKey("not an ed25519 key".into()))?; let secret_slice: &[u8] = key_data.private.as_ref(); - let secret_bytes: [u8; 32] = secret_slice - .try_into() - .map_err(|_| RelicarioError::DeviceKey("invalid key length".into()))?; + if secret_slice.len() != 32 { + return Err(RelicarioError::DeviceKey("invalid key length".into())); + } + let mut seed = Zeroizing::new([0u8; 32]); + seed.copy_from_slice(secret_slice); + Ok(seed) +} - let signing_key = SigningKey::from_bytes(&secret_bytes); +/// Sign data with an OpenSSH private key, returning base64 signature. +pub fn sign(private_key_openssh: &str, data: &[u8]) -> Result { + use base64::Engine; + + let seed = extract_ed25519_seed(private_key_openssh)?; + let signing_key = SigningKey::from_bytes(&seed); let signature = signing_key.sign(data); Ok(base64::engine::general_purpose::STANDARD.encode(signature.to_bytes())) } diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index f362ec2..e92a28b 100644 --- a/crates/relicario-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -93,7 +93,7 @@ pub mod import_lastpass; pub use import_lastpass::{parse_lastpass_csv, ImportWarning}; pub mod device; -pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify}; +pub use device::{fingerprint, DeviceEntry, RevokedEntry, extract_ed25519_seed, generate_keypair, sign, verify}; pub mod org; pub use org::{ diff --git a/crates/relicario-core/src/org.rs b/crates/relicario-core/src/org.rs index caceaf2..43094ef 100644 --- a/crates/relicario-core/src/org.rs +++ b/crates/relicario-core/src/org.rs @@ -206,18 +206,27 @@ impl OrgManifest { Self { schema_version: 1, entries: Vec::new() } } - /// Return only entries whose collection is in `member.collections`. - pub fn filter_for_member(&self, member: &OrgMember) -> Self { - let granted: std::collections::HashSet<&str> = - member.collections.iter().map(|s| s.as_str()).collect(); + /// Keep only entries whose collection slug is in `granted`. + /// + /// NOTE: phase-1 grant filtering is a SOFT/UX filter — the org member holds + /// the single org key and can decrypt everything; per-collection crypto + /// isolation is phase-2. This hides ungranted entries from the UI; it is not + /// a cryptographic boundary. + pub fn filter_by_collections(&self, granted: &[String]) -> Self { + let set: std::collections::HashSet<&str> = granted.iter().map(|s| s.as_str()).collect(); Self { schema_version: self.schema_version, entries: self.entries.iter() - .filter(|e| granted.contains(e.collection.as_str())) + .filter(|e| set.contains(e.collection.as_str())) .cloned() .collect(), } } + + /// Return only entries whose collection is in `member.collections`. + pub fn filter_for_member(&self, member: &OrgMember) -> Self { + self.filter_by_collections(&member.collections) // delegate — single source + } } impl Default for OrgManifest { @@ -418,6 +427,44 @@ mod tests { assert_eq!(filtered.entries[0].collection, "prod"); } + #[test] + fn filter_by_collections_keeps_only_granted_entries() { + let mut manifest = OrgManifest::new(); + for (title, coll) in [("A", "prod"), ("B", "dev"), ("C", "secret")] { + manifest.entries.push(OrgManifestEntry { + id: ItemId::new(), + r#type: crate::item_types::ItemType::SecureNote, + title: title.into(), + tags: vec![], + modified: 0, + trashed_at: None, + collection: coll.into(), + }); + } + + let filtered = manifest.filter_by_collections(&["prod".into(), "dev".into()]); + assert_eq!(filtered.entries.len(), 2); + assert!(filtered.entries.iter().all(|e| e.collection != "secret")); + assert_eq!(filtered.schema_version, manifest.schema_version); + } + + #[test] + fn filter_by_collections_empty_granted_returns_empty() { + let mut manifest = OrgManifest::new(); + manifest.entries.push(OrgManifestEntry { + id: ItemId::new(), + r#type: crate::item_types::ItemType::SecureNote, + title: "X".into(), + tags: vec![], + modified: 0, + trashed_at: None, + collection: "prod".into(), + }); + + let filtered = manifest.filter_by_collections(&[]); + assert_eq!(filtered.entries.len(), 0); + } + #[test] fn generate_org_key_is_32_bytes() { let key = generate_org_key(); diff --git a/crates/relicario-wasm/Cargo.toml b/crates/relicario-wasm/Cargo.toml index ffd5fae..08065cc 100644 --- a/crates/relicario-wasm/Cargo.toml +++ b/crates/relicario-wasm/Cargo.toml @@ -14,7 +14,7 @@ wasm-bindgen = "0.2" serde-wasm-bindgen = "0.6" serde_json = "1" serde = { version = "1", features = ["derive"] } -zeroize = "1" +zeroize = { version = "1", features = ["derive"] } getrandom = { version = "0.2", features = ["js"] } ed25519-dalek = { version = "2", features = ["rand_core"] } base64 = "0.22" diff --git a/crates/relicario-wasm/src/device.rs b/crates/relicario-wasm/src/device.rs index 68fafc4..347b2a7 100644 --- a/crates/relicario-wasm/src/device.rs +++ b/crates/relicario-wasm/src/device.rs @@ -3,7 +3,8 @@ use std::sync::Mutex; use once_cell::sync::Lazy; -use zeroize::Zeroizing; +use serde::{Deserialize, Serialize}; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; use relicario_core::device as core_device; @@ -20,6 +21,34 @@ struct DeviceState { deploy_public: String, } +/// Serialization-safe mirror of `DeviceState`. +/// +/// Fields are plain `String` (not `Zeroizing`) because serde requires `Sized` +/// owned types. The byte vector produced by `export_state_bytes` is wrapped in +/// `Zeroizing>`; `import_state_bytes` re-wraps the private key fields +/// back into `Zeroizing` on decode. +/// +/// # Security note +/// +/// `ZeroizeOnDrop` wipes every `String` field (including the `signing_private` / +/// `deploy_private` device PRIVATE keys) when the struct drops, on EVERY path — +/// success, the `?` error path of `serde_json::to_vec`, and panics — so the +/// transient plaintext copy created in `export_state_bytes` leaves no residue in +/// heap memory. Because the derived `ZeroizeOnDrop` adds a `Drop` impl, fields +/// cannot be moved out (Rust E0509); `import_state_bytes` uses `mem::take` to +/// transfer the private-key Strings into `Zeroizing` without copying, +/// leaving empty Strings that the drop-zeroize no-ops. The remaining invariants: +/// (a) `persist_device_key` in lib.rs encrypts the bytes before returning to JS, +/// (b) `export_state_bytes` is NOT a `#[wasm_bindgen]` export. +#[derive(Serialize, Deserialize, Zeroize, ZeroizeOnDrop)] +struct PersistedDeviceState { + name: String, + signing_private: String, + signing_public: String, + deploy_private: String, + deploy_public: String, +} + /// Register a new device, storing the keypairs internally and returning /// only the public keys. Private keys never leave WASM memory. pub fn register_device(name: &str) -> Result<(String, String), String> { @@ -69,3 +98,81 @@ pub fn get_device_info() -> Option<(String, String, String)> { pub fn clear_device() { *DEVICE_STATE.lock().unwrap() = None; } + +/// Serialize the current `DEVICE_STATE` to JSON bytes, wrapped in `Zeroizing`. +/// +/// # Security +/// +/// The returned bytes are **plaintext secret material** — they contain the +/// ed25519 private keys (signing + deploy) in OpenSSH PEM format. The caller in +/// `lib.rs` encrypts them immediately under the vault master key before returning +/// anything to JS. This function **must never** be exposed as a `#[wasm_bindgen]` +/// export. +/// +/// The transient `persisted` clone holds plaintext private-key copies, but +/// `PersistedDeviceState` derives `ZeroizeOnDrop`, so those copies are wiped when +/// `persisted` drops — on the success path, the `?` error path of +/// `serde_json::to_vec`, and on any panic — leaving no heap residue. +/// +/// # Errors +/// +/// Returns `Err("no device registered")` if `DEVICE_STATE` is empty. +pub fn export_state_bytes() -> Result>, String> { + let guard = DEVICE_STATE.lock().unwrap(); + let state = guard.as_ref().ok_or_else(|| "no device registered".to_string())?; + let persisted = PersistedDeviceState { + name: state.name.clone(), + signing_private: state.signing_private.as_str().to_owned(), + signing_public: state.signing_public.clone(), + deploy_private: state.deploy_private.as_str().to_owned(), + deploy_public: state.deploy_public.clone(), + }; + let bytes = serde_json::to_vec(&persisted).map_err(|e| e.to_string())?; + Ok(Zeroizing::new(bytes)) + // `persisted` (and its plaintext private-key copies) is zeroized here on drop. +} + +/// Deserialize a `PersistedDeviceState` from raw bytes and repopulate +/// `DEVICE_STATE`. Private key fields are re-wrapped in `Zeroizing`. +/// +/// # Security +/// +/// `PersistedDeviceState` derives `ZeroizeOnDrop`, which adds a `Drop` impl, so +/// its fields cannot be moved out (Rust E0509). We use `mem::take` to transfer +/// each `String` into the new `DeviceState` (wrapping the private keys in +/// `Zeroizing`) — this moves the heap buffer without copying, leaving an +/// empty `String` behind whose drop-zeroize is a no-op. No secret copy survives. +/// +/// # Errors +/// +/// Returns `Err(...)` if `bytes` is not valid JSON matching `PersistedDeviceState`. +pub fn import_state_bytes(bytes: &[u8]) -> Result<(), String> { + let mut persisted: PersistedDeviceState = + serde_json::from_slice(bytes).map_err(|e| e.to_string())?; + let state = DeviceState { + name: std::mem::take(&mut persisted.name), + signing_private: Zeroizing::new(std::mem::take(&mut persisted.signing_private)), + signing_public: std::mem::take(&mut persisted.signing_public), + deploy_private: Zeroizing::new(std::mem::take(&mut persisted.deploy_private)), + deploy_public: std::mem::take(&mut persisted.deploy_public), + }; + *DEVICE_STATE.lock().unwrap() = Some(state); + Ok(()) +} + +/// Extract the raw 32-byte ed25519 seed from the registered device's signing key. +/// +/// Used by `org_unwrap_key` (which calls `relicario_core::org::unwrap_org_key`) +/// so the private key never has to cross to JS. The seed is wrapped in +/// `Zeroizing<[u8; 32]>` and wiped on drop. +/// +/// # Errors +/// +/// Returns `Err("no device registered")` if `DEVICE_STATE` is empty, or an +/// error from `extract_ed25519_seed` if the stored key can't be parsed. +pub fn signing_seed() -> Result, String> { + let guard = DEVICE_STATE.lock().unwrap(); + let state = guard.as_ref().ok_or_else(|| "no device registered".to_string())?; + core_device::extract_ed25519_seed(state.signing_private.as_str()) + .map_err(|e| e.to_string()) +} diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index a84a47f..5ffee4e 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -19,6 +19,7 @@ use relicario_core::{derive_master_key, imgsecret, KdfParams}; /// `lock(handle)` remains available as the explicit early-cleanup path; the /// `Drop` impl is the safety net that catches code paths which forget to call /// `lock` before letting the handle go out of scope. +#[derive(Debug)] #[wasm_bindgen] pub struct SessionHandle(u32); @@ -607,6 +608,308 @@ pub fn wasm_unwrap_recovery_qr( Ok(recovered.to_vec()) } +// ── Org vault WASM bridge ──────────────────────────────────────────────────── + +/// Encrypt the registered device key under the current vault master key and +/// return CIPHERTEXT for JS to persist (e.g. `chrome.storage.local.device_key_enc`). +/// +/// The device private key NEVER crosses to JS — only this encrypted blob does. +/// On the decrypt side, `restore_device_key` decrypts inside WASM and repopulates +/// `DEVICE_STATE` without ever returning plaintext to JS. +#[wasm_bindgen] +pub fn persist_device_key(handle: &SessionHandle) -> Result, JsError> { + need_key(handle)?; + let plain = device::export_state_bytes().map_err(|e| JsError::new(&e))?; + session::with(handle.0, |k| relicario_core::crypto::encrypt(k, &plain)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string())) +} + +/// Decrypt a persisted device-key blob under the master key and repopulate +/// `DEVICE_STATE` (decryption happens INSIDE WASM; plaintext never reaches JS). +/// Call this at unlock time after `restore_device_key` to restore device signing +/// capability across service-worker restarts. +#[wasm_bindgen] +pub fn restore_device_key(handle: &SessionHandle, encrypted: &[u8]) -> Result<(), JsError> { + need_key(handle)?; + let plain = session::with(handle.0, |k| relicario_core::crypto::decrypt(k, encrypted)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + let plain = Zeroizing::new(plain); + device::import_state_bytes(&plain).map_err(|e| JsError::new(&e)) +} + +/// Unwrap a member's ECIES-wrapped org master key into a session handle, using +/// the registered device key held in `DEVICE_STATE` (restored at unlock via +/// `restore_device_key`). The device private key never crosses to JS. +/// +/// `keys_blob` is the raw wrapped-key blob at `keys/.enc` in the org +/// repo — produced by `relicario_core::org::wrap_org_key`. +/// +/// The org key is held in the same Zeroizing WASM session registry as the personal +/// master key; org items share the personal `.enc` AEAD format, so the returned +/// handle works with `item_decrypt`/`manifest_decrypt` unchanged. +#[wasm_bindgen] +pub fn org_unwrap_key(keys_blob: &[u8]) -> Result { + let seed = device::signing_seed() + .map_err(|e| JsError::new(&format!("device key unavailable: {e}")))?; + let org_key = relicario_core::org::unwrap_org_key(keys_blob, &seed) + .map_err(|e| JsError::new(&format!("org unwrap failed: {e}")))?; + // image_secret slot is unused for org sessions; store a zeroized placeholder. + let handle = session::insert(org_key, Zeroizing::new([0u8; 32])); + Ok(SessionHandle(handle)) +} + +/// Decrypt an org manifest blob (manifest.enc in the org repo) with an org +/// session handle. The org manifest is a dedicated OrgManifest type (NOT the +/// personal Manifest); core's decrypt_org_manifest deserializes it. +#[wasm_bindgen] +pub fn org_manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result { + need_key(handle)?; + let out = session::with(handle.0, |k| relicario_core::decrypt_org_manifest(encrypted, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + js_value_for(&out) +} + +/// Decrypt an org manifest blob AND grant-filter it to `granted` collection slugs, +/// for the READ path. Filtering happens in core (`filter_by_collections`), so +/// ungranted entries never cross to JS. (SOFT phase-1 UX filter — see core +/// doc-comment; the member holds the org key, this is not crypto isolation.) +/// Dev-C writes use the UNFILTERED `org_manifest_decrypt` instead (re-encrypting a +/// filtered subset would wipe ungranted entries). +#[wasm_bindgen] +pub fn org_manifest_decrypt_filtered( + handle: &SessionHandle, + encrypted: &[u8], + granted: Vec, +) -> Result { + need_key(handle)?; + let manifest = session::with(handle.0, |k| relicario_core::decrypt_org_manifest(encrypted, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + let filtered = manifest.filter_by_collections(&granted); + js_value_for(&filtered) +} + +/// Encrypt an OrgManifest (JSON) with an org session handle, for the write track +/// (Dev-C never touches WASM — this is exposed here so they don't have to). +#[wasm_bindgen] +pub fn org_manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Result, JsError> { + need_key(handle)?; + let m: relicario_core::OrgManifest = serde_json::from_str(manifest_json) + .map_err(|e| JsError::new(&format!("org manifest json: {e}")))?; + session::with(handle.0, |k| relicario_core::encrypt_org_manifest(&m, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string())) +} + +/// Tests that modify DEVICE_STATE must serialize to prevent races. +/// Acquired by any test in org_tests or device_persist_tests that +/// calls register_device / clear_device / persist_device_key / restore_device_key. +#[cfg(test)] +static DEVICE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[cfg(test)] +mod org_tests { + use super::*; + use relicario_core::org::wrap_org_key; + use zeroize::Zeroizing; + + #[test] + fn org_manifest_round_trip_via_handle() { + use relicario_core::{OrgManifest, OrgManifestEntry, ItemType, ItemId, decrypt_org_manifest}; + session::clear(); + let key_bytes = [0xABu8; 32]; + let h = session::insert(Zeroizing::new(key_bytes), Zeroizing::new([0u8; 32])); + let handle = SessionHandle(h); + let key = Zeroizing::new(key_bytes); + + let mut manifest = OrgManifest::new(); + manifest.entries.push(OrgManifestEntry { + id: ItemId::new(), + r#type: ItemType::SecureNote, + title: "Org Test Item".into(), + tags: vec!["tag1".into()], + modified: 1_234_567_890, + trashed_at: None, + collection: "prod".into(), + }); + + let manifest_json = serde_json::to_string(&manifest).unwrap(); + let encrypted = org_manifest_encrypt(&handle, &manifest_json).unwrap(); + assert!(!encrypted.is_empty()); + + // Decrypt via core directly (avoids js-sys/serde_wasm_bindgen on native). + let parsed: OrgManifest = decrypt_org_manifest(&encrypted, &key).unwrap(); + assert_eq!(parsed.entries.len(), 1); + assert_eq!(parsed.entries[0].collection, "prod"); + assert_eq!(parsed.entries[0].title, "Org Test Item"); + assert_eq!(parsed.entries[0].tags, vec!["tag1".to_string()]); + assert_eq!(parsed.schema_version, 1); + + // Random nonces: two encryptions of the same plaintext must differ. + let encrypted2 = org_manifest_encrypt(&handle, &manifest_json).unwrap(); + assert_ne!(encrypted, encrypted2, "nonces must differ"); + } + + #[test] + fn org_unwrap_key_yields_a_session_that_decrypts_org_blobs() { + // Serialize with all tests that touch DEVICE_STATE. + let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + device::clear_device(); + + // Register a device so DEVICE_STATE is populated; use its signing public key + // to wrap a known org key. org_unwrap_key now reads the seed from DEVICE_STATE + // — the device_private_openssh param is gone in the 4.5a refactor. + let org_key = Zeroizing::new([7u8; 32]); + let (signing_pub, _deploy_pub) = device::register_device("test-dev").unwrap(); + let wrapped = wrap_org_key(&org_key, &signing_pub).unwrap(); + + let handle = org_unwrap_key(&wrapped).unwrap(); + // item_encrypt is native-safe: returns Vec; JsError is only reachable on + // the error path, which is not exercised here. + let ct = item_encrypt( + &handle, + r#"{"id":"a1b2c3d4e5f6a7b8","title":"Test","type":"secure_note","created":0,"modified":0,"core":{"type":"secure_note","body":"x"}}"#, + ) + .unwrap(); + // item_decrypt calls serde_wasm_bindgen::Serializer which panics off-wasm. + // Use relicario_core::decrypt_item directly, mirroring the + // manifest_round_trip_via_handle approach in session_tests. + let pt: relicario_core::Item = relicario_core::decrypt_item(&ct, &org_key).unwrap(); + assert!(format!("{:?}", pt.core).contains("SecureNote")); + } +} + +#[cfg(test)] +mod device_persist_tests { + use super::*; + + /// 1. Persist → restore round-trip. + /// + /// After persisting the device state and simulating a SW restart (clear_device), + /// restoring must bring back the exact same name and signing_public key. + #[test] + fn persist_restore_round_trip() { + let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + session::clear(); + device::clear_device(); + + // A master-key session (no Argon2id needed — we construct the key directly). + let master_key = Zeroizing::new([0x42u8; 32]); + let h = session::insert(master_key, Zeroizing::new([0u8; 32])); + let handle = SessionHandle(h); + + // Register a device; capture name + signing public key before persist. + device::register_device("my-laptop").unwrap(); + let (name_before, signing_pub_before, _) = device::get_device_info().unwrap(); + + // Encrypt the device state under the master key. + let enc = persist_device_key(&handle).unwrap(); + + // Simulate a SW restart: wipe DEVICE_STATE in-memory. + device::clear_device(); + assert!(device::get_device_info().is_none(), "DEVICE_STATE must be cleared"); + + // Decrypt and repopulate. + restore_device_key(&handle, &enc).unwrap(); + + // Name and signing_public must survive the full round-trip. + let (name_after, signing_pub_after, _) = device::get_device_info().unwrap(); + assert_eq!(name_before, name_after, "device name must survive persist/restore"); + assert_eq!( + signing_pub_before, signing_pub_after, + "signing_public must survive persist/restore" + ); + } + + /// 2. org_unwrap_key works post-restore. + /// + /// After a persist→clear→restore cycle, org_unwrap_key must be able to unwrap + /// an org key that was wrapped to the device's signing public key. + #[test] + fn org_unwrap_key_works_after_restore() { + let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + session::clear(); + device::clear_device(); + + let master_key = Zeroizing::new([0x43u8; 32]); + let h = session::insert(master_key, Zeroizing::new([0u8; 32])); + let handle = SessionHandle(h); + + // Register device; capture signing_pub for key wrapping. + let (signing_pub, _deploy_pub) = device::register_device("my-laptop").unwrap(); + + // Full persist → wipe → restore cycle (simulates SW restart). + let enc = persist_device_key(&handle).unwrap(); + device::clear_device(); + restore_device_key(&handle, &enc).unwrap(); + + // Wrap a known org key to the device's signing public key. + let known_org_key = Zeroizing::new([0x77u8; 32]); + let wrapped = relicario_core::org::wrap_org_key(&known_org_key, &signing_pub).unwrap(); + + // Unwrap using the refactored org_unwrap_key (reads seed from DEVICE_STATE). + let org_handle = org_unwrap_key(&wrapped).unwrap(); + + // Encrypt an item under the org session, then decrypt with the known key + // directly (avoids js-sys/serde_wasm_bindgen on native — mirrors existing org test). + let ct = item_encrypt( + &org_handle, + r#"{"id":"a1b2c3d4e5f6a7b8","title":"Test","type":"secure_note","created":0,"modified":0,"core":{"type":"secure_note","body":"x"}}"#, + ) + .unwrap(); + let pt: relicario_core::Item = + relicario_core::decrypt_item(&ct, &known_org_key).unwrap(); + assert!(format!("{:?}", pt.core).contains("SecureNote")); + } + + /// 3. Ciphertext is NOT plaintext. + /// + /// The blob returned by persist_device_key must not contain the raw PEM private + /// key header — proves the device key is encrypted, never handed to JS in clear. + #[test] + fn persist_output_is_ciphertext_not_plaintext() { + let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + session::clear(); + device::clear_device(); + + let master_key = Zeroizing::new([0x44u8; 32]); + let h = session::insert(master_key, Zeroizing::new([0u8; 32])); + let handle = SessionHandle(h); + + device::register_device("my-laptop").unwrap(); + let enc = persist_device_key(&handle).unwrap(); + + // Encrypted bytes must NOT contain the OpenSSH PEM header verbatim. + // The XChaCha20-Poly1305 ciphertext is randomized; the ASCII string + // "BEGIN OPENSSH PRIVATE KEY" cannot appear in it. + let as_lossy = String::from_utf8_lossy(&enc); + assert!( + !as_lossy.contains("BEGIN OPENSSH PRIVATE KEY"), + "persist_device_key must return ciphertext, not the raw PEM private key (device privkey must never reach JS)" + ); + } + + /// 4. org_unwrap_key errors when no device is registered. + /// + /// `org_unwrap_key` wraps its error in `JsError`, which panics on non-wasm + /// targets (established constraint in this codebase — see session_tests comment). + /// We test `device::signing_seed` directly instead: that is the codepath + /// `org_unwrap_key` calls first, and it is what produces the "no device + /// registered" error that `org_unwrap_key` surfaces as "device key unavailable". + #[test] + fn org_unwrap_key_errors_when_no_device_registered() { + let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + device::clear_device(); + + let err = device::signing_seed() + .expect_err("signing_seed must fail when no device is registered"); + assert_eq!(err, "no device registered", "error message must match"); + } +} + #[cfg(test)] mod session_tests { use super::*; diff --git a/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md b/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md index 094a813..94c273e 100644 --- a/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md +++ b/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md @@ -4,7 +4,7 @@ **Goal:** Give the extension service worker the data layer to switch into an org vault, unwrap the org master key into a Zeroizing WASM handle, and serve a grant-filtered org manifest — no UI. -**Architecture:** Org reuses the existing key-agnostic WASM session registry (`relicario-wasm/src/session.rs`) and the existing `item_decrypt`/`manifest_decrypt` AEAD (org items share the personal `.enc` format, org key used directly). The only new WASM function is `org_unwrap_key`. In the SW, a new multi-context session replaces the single-handle model, and a new `org-vault.ts` module mirrors `vault.ts` for org reads. Plans 2 (read UI) and 3 (write) consume the SW message contract this plan produces — they never touch WASM. +**Architecture:** Org reuses the existing key-agnostic WASM session registry (`relicario-wasm/src/session.rs`). Org ITEMS share the personal `.enc` format and reuse `item_decrypt`/`item_encrypt` on the org handle directly. The org MANIFEST is a dedicated `OrgManifest` type (NOT the personal `Manifest`) — it uses `org_manifest_decrypt`/`org_manifest_encrypt` (Task 2, mirroring `manifest_decrypt`/`manifest_encrypt` in pattern but wrapping core's dedicated org manifest AEAD). New WASM functions: `org_unwrap_key` (Task 1) + `org_manifest_decrypt`/`org_manifest_encrypt` (Task 2). In the SW, a new multi-context session replaces the single-handle model, and a new `org-vault.ts` module mirrors `vault.ts` for org reads. Plans 2 (read UI) and 3 (write) consume the SW message contract this plan produces — they never touch WASM. **Tech Stack:** Rust (relicario-core/wasm), wasm-bindgen, TypeScript (extension service worker), vitest + happy-dom. @@ -21,8 +21,8 @@ ## File Structure -- `crates/relicario-wasm/src/lib.rs` — add `#[wasm_bindgen] org_unwrap_key`. (Reuses `session::insert`; reuses existing `manifest_decrypt`/`item_decrypt`/`item_encrypt`/`manifest_encrypt` on the returned handle.) -- `crates/relicario-core/src/manifest.rs` — ensure a `ManifestEntry` carries an optional `collection: Option` so the org manifest round-trips through the existing manifest (de)serialization. (Verify first; only add if absent.) +- `crates/relicario-wasm/src/lib.rs` — add `#[wasm_bindgen] org_unwrap_key` (Task 1) + `org_manifest_decrypt`/`org_manifest_encrypt` (Task 2). Org items reuse `item_decrypt`/`item_encrypt` on the returned handle. Org manifest uses the dedicated `org_manifest_decrypt`/`org_manifest_encrypt` (NOT `manifest_decrypt`/`manifest_encrypt` — the org manifest is a different type `OrgManifest`). +- ~~`crates/relicario-core/src/manifest.rs`~~ — NOT modified. The org manifest is a dedicated `OrgManifest` type, not the personal `Manifest`. Core already ships its AEAD wrappers. No change to `ManifestEntry` was needed or made. - `extension/src/wasm.d.ts` — declare `org_unwrap_key`. - `extension/src/service-worker/session.ts` — replace single-handle model with a context map (personal + orgs); zero ALL on lock/expiry. - `extension/src/service-worker/org-config.ts` *(new)* — `orgConfigs` read/write over `chrome.storage.local`. @@ -44,7 +44,7 @@ **Interfaces:** - Consumes: `relicario_core::org::unwrap_org_key(wrapped: &[u8], ed25519_seed: &Zeroizing<[u8;32]>) -> Result>` (`crates/relicario-core/src/org.rs:299`); `session::insert(master_key, image_secret) -> u32` (`crates/relicario-wasm/src/session.rs`). -- Produces: `org_unwrap_key(keys_blob: &[u8], device_private_key_base64: &str) -> Result`. The returned handle is an ordinary `SessionHandle` — callers use the existing `item_decrypt`/`item_encrypt`/`manifest_decrypt`/`manifest_encrypt` with it. +- Produces: `org_unwrap_key(keys_blob: &[u8]) -> Result` (reads `DEVICE_STATE` internally — no private-key param crosses JS). The returned handle is an ordinary `SessionHandle` — callers use the existing `item_decrypt`/`item_encrypt`/`manifest_decrypt`/`manifest_encrypt` with it. - [ ] **Step 1: Confirm the device-key form.** Read how `device_private_key` is produced — `crates/relicario-wasm/src/lib.rs` `register_device`/`generate_device_keypair` and `crates/relicario-core/src/device.rs`. Determine whether `private_key_base64` is the raw 32-byte ed25519 seed or an OpenSSH blob, and write `org_unwrap_key` to decode it to the 32-byte seed `Zeroizing<[u8;32]>` that `unwrap_org_key` expects. Note the finding in a code comment. @@ -120,55 +120,42 @@ git commit -m "feat(wasm): org_unwrap_key — ECIES unwrap into a session handle --- -### Task 2: Org manifest `collection` field round-trips +### Task 2: Org manifest over WASM + faithful OrgManifest/OrgManifestEntry TS mirror + +> **Re-scoped after investigation.** The original Task 2 ("add collection to the personal +> ManifestEntry") was based on a wrong assumption and was rejected. Investigation confirmed +> that the org manifest is a DEDICATED type (`OrgManifest`/`OrgManifestEntry`), NOT the +> personal `Manifest`. Core already ships `encrypt_org_manifest`/`decrypt_org_manifest` +> AEAD wrappers (`crates/relicario-core/src/vault.rs:56,62`, re-exported at crate root). +> Adding `collection` to the personal `ManifestEntry` would be wrong and was not done. +> The PM ratified this re-scope (Option A). This task exposes the existing core wrappers +> over WASM and mirrors the org manifest types into TS. **Files:** -- Modify: `crates/relicario-core/src/manifest.rs` -- Modify: `extension/src/shared/types.ts` -- Test: `crates/relicario-core/tests/format_v2.rs` (or the manifest test module) +- Modify: `crates/relicario-wasm/src/lib.rs` — add `org_manifest_decrypt` + `org_manifest_encrypt` after `org_unwrap_key`; add round-trip test in `org_tests`. +- Modify: `extension/src/wasm.d.ts` — declare both new functions. +- Modify: `extension/src/shared/types.ts` — add `OrgManifestEntry` + `OrgManifest` interfaces (faithful/lean; no personal-only fields). **Interfaces:** -- Produces: `ManifestEntry.collection: Option` (serde `skip_serializing_if = "Option::is_none"`), mirrored in TS as `collection?: string`. +- Produces: `org_manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result` — mirrors `manifest_decrypt`; org items remain personal Items. +- Produces: `org_manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Result, JsError>` — mirrors `manifest_encrypt`. +- Produces: `OrgManifestEntry` TS interface: `{id, type, title, tags[], modified, trashed_at?, collection}`. `collection` is REQUIRED. No personal-only fields (no attachment_summaries/icon_hint/favorite/group). +- Produces: `OrgManifest` TS interface: `{schema_version, entries: OrgManifestEntry[]}`. +- Core import path confirmed: `relicario_core::decrypt_org_manifest` / `relicario_core::encrypt_org_manifest` / `relicario_core::OrgManifest` — all re-exported at crate root (`lib.rs:81-83`, `lib.rs:99`). -- [ ] **Step 1: Check current state.** Grep `crates/relicario-core/src/manifest.rs` for `collection`. If the org manifest already round-trips (org CLI works, so it likely uses a dedicated type or already has the field), this task is a no-op verification — confirm with a test and skip to commit. If `ManifestEntry` lacks `collection`, proceed. +- [x] **Step 1 (TDD RED):** Added test `org_manifest_round_trip_via_handle` in `org_tests` before implementing the fns. Confirmed compile error: `cannot find function 'org_manifest_encrypt' in this scope`. -- [ ] **Step 2: Write the failing test** +- [x] **Step 2 (Implement):** Added `org_manifest_decrypt` + `org_manifest_encrypt` in `lib.rs` right after `org_unwrap_key`, mirroring the existing `manifest_decrypt`/`manifest_encrypt` pattern exactly. Same `need_key(handle)?` guard, same `session::with`, same `js_value_for(&out)`. Swapped type + core fn. -```rust -#[test] -fn manifest_entry_round_trips_collection_slug() { - let json = r#"{"id":"a1","title":"db","collection":"prod-infra","modified":1}"#; - let entry: ManifestEntry = serde_json::from_str(json).unwrap(); - assert_eq!(entry.collection.as_deref(), Some("prod-infra")); - let back = serde_json::to_string(&entry).unwrap(); - assert!(back.contains("prod-infra")); -} -``` +- [x] **Step 3 (TDD GREEN):** `cargo test -p relicario-wasm org_manifest` → 1 passed. -- [ ] **Step 3: Run to verify it fails** +- [x] **Step 4 (TS types):** Added `OrgManifestEntry` + `OrgManifest` to `extension/src/shared/types.ts` (faithful/lean). Added `org_manifest_decrypt`/`org_manifest_encrypt` declarations to `extension/src/wasm.d.ts`. -Run: `cargo test -p relicario-core manifest_entry_round_trips_collection_slug` -Expected: FAIL (unknown field or missing accessor) — or PASS immediately if the field already exists (then this task is verification-only). - -- [ ] **Step 4: Add the field if absent** - -```rust -#[serde(skip_serializing_if = "Option::is_none", default)] -pub collection: Option, -``` - -- [ ] **Step 5: Run to verify it passes** - -Run: `cargo test -p relicario-core manifest` -Expected: PASS, no other manifest test regressed. - -- [ ] **Step 6: Mirror in TS + commit** - -Add `collection?: string;` to the `ManifestEntry` interface in `extension/src/shared/types.ts`. +- [x] **Step 5 (gates):** All five gates green (see commit). ```bash -git add crates/relicario-core/src/manifest.rs extension/src/shared/types.ts -git commit -m "feat(core): ManifestEntry carries optional collection slug" +git add crates/relicario-wasm/src/lib.rs extension/src/wasm.d.ts extension/src/shared/types.ts +git commit -m "feat(wasm,ext): org_manifest_decrypt/encrypt over WASM + faithful OrgManifest TS mirror" ``` --- @@ -336,7 +323,7 @@ git commit -m "feat(ext/sw): org config storage + org_list_configs message" - Test: `extension/src/service-worker/__tests__/org-vault.test.ts` **Interfaces:** -- Consumes: `createGitHost` (`service-worker/git-host.ts`); `org_unwrap_key` (Task 1); device key from `chrome.storage.local.device_private_key`; `wasm.manifest_decrypt` (existing). +- Consumes: `createGitHost` (`service-worker/git-host.ts`); `org_unwrap_key` (Task 1); device key from `DEVICE_STATE` via `w.get_device_info()`; `wasm.org_manifest_decrypt_filtered` (Task 5a). - Produces: `openOrg(cfg: OrgConfig): Promise` where `OrgHandleState = { handle: SessionHandle; grants: string[]; offline: boolean }`; `listOrgItems(state): ManifestEntry[]` (filtered to `grants`); `getOrgItem(state, id): Promise`; `listOrgCollections(state): Collection[]`. - [ ] **Step 1: Write the failing test** (mock the GitHost + wasm boundary as `router.test.ts` does) @@ -420,7 +407,7 @@ git commit -m "feat(ext/sw): org-vault — unwrap, fetch, grant-filter manifest" - Test: `extension/src/service-worker/__tests__/org-vault.test.ts` **Interfaces:** -- Produces SW messages: `org_switch {context}` → `{ ok, data: { context, offline } }`; `org_list_items` → `{ ok, data: ManifestEntry[] }`; `org_get_item {id}` → `{ ok, data: Item }`; `org_list_collections` → `{ ok, data: Collection[] }`. On a git network error during switch, set `offline: true` and serve the last-cached manifest read-only. +- Produces SW messages: `org_switch {context}` → `{ ok, data: { context, offline } }`; `org_list_items` → `{ ok, data: OrgManifestEntry[] }`; `org_get_item {id}` → `{ ok, data: Item }`; `org_list_collections` → `{ ok, data: Collection[] }`. On a git network error during switch, set `offline: true` and serve the last-cached manifest read-only. - [ ] **Step 1: Write the failing test** @@ -460,8 +447,8 @@ Plans 2 and 3 are UI-only and talk to the SW exclusively through these messages - `org_list_configs` → `{ ok, data: OrgConfigSummary[] }` where `OrgConfigSummary = { orgId, displayName }` - `org_switch { context: 'personal' | }` → `{ ok, data: { context, offline: boolean } }` -- `org_list_items` → `{ ok, data: ManifestEntry[] }` (already grant-filtered; entries carry `collection`) -- `org_get_item { id }` → `{ ok, data: Item }` +- `org_list_items` → `{ ok, data: OrgManifestEntry[] }` (already grant-filtered; `collection` required — entries are the lean org shape, NOT personal ManifestEntry) +- `org_get_item { id }` → `{ ok, data: Item }` (org items are personal `Item`s encrypted with the org key; `item_decrypt` is unchanged) - `org_list_collections` → `{ ok, data: Collection[] }` where `Collection = { slug, display_name }` The SW holds the org context after `org_switch`; subsequent `org_list_items`/`org_get_item` operate on the current context until the next `org_switch` (including back to `'personal'`). Plan 3 adds `org_add_item`/`org_update_item`/`org_delete_item` against this same context model. diff --git a/extension/src/popup/components/__tests__/item-list.test.ts b/extension/src/popup/components/__tests__/item-list.test.ts new file mode 100644 index 0000000..057bbaf --- /dev/null +++ b/extension/src/popup/components/__tests__/item-list.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// item-list.ts imports getState/setState/sendMessage/navigate/escapeHtml/openVaultTab +// from shared/state — mock the whole module (the established component-test idiom). +vi.mock('../../../shared/state', () => ({ + getState: vi.fn(() => ({ orgContext: 'personal', entries: [], searchQuery: '', selectedIndex: 0 })), + setState: vi.fn(), + sendMessage: vi.fn().mockResolvedValue({ ok: true, data: { items: [] } }), + navigate: vi.fn(), + escapeHtml: (s: string) => s, + popOutToTab: vi.fn(), + isInTab: () => false, + openVaultTab: vi.fn(), +})); + +import { renderItemList } from '../item-list'; +import { getState, sendMessage } from '../../../shared/state'; + +const mockGetState = getState as ReturnType; +const send = sendMessage as ReturnType; + +describe('popup/item-list in org context', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = '
'; + }); + + // Task 3 (loading-design-v2) — the switcher loads org entries into state + // before navigating. renderItemList renders whatever is in state.entries and + // does NOT fire its own org_list_items load. This test asserts that: given + // pre-populated state.entries, the rows appear; and no org_list_items call + // is made by renderItemList itself. + it('renders org entries already in state.entries without re-fetching', () => { + mockGetState.mockReturnValue({ + orgContext: 'org-1', + entries: [['a', { + id: 'a', type: 'login', title: 'db', tags: [], collection: 'prod-infra', + modified: 1, favorite: false, attachment_summaries: [], + }]], + searchQuery: '', + selectedIndex: 0, + }); + const app = document.getElementById('app')!; + + renderItemList(app); + + expect(app.textContent).toContain('db'); + expect(send).not.toHaveBeenCalledWith({ type: 'org_list_items' }); + }); + + // Task 3 — read-only this plan: no add affordance in org context. Dev-C's + // write plan re-introduces it. + it('hides the "+ new" affordance in org context', () => { + mockGetState.mockReturnValue({ orgContext: 'org-1', entries: [], searchQuery: '', selectedIndex: 0 }); + const app = document.getElementById('app')!; + + renderItemList(app); + + expect(app.querySelector('#new-btn')).toBeNull(); + }); + + // Personal path is unchanged: render shows the add affordance and does not + // auto-fire a list load. + it('keeps the "+ new" affordance in personal context', () => { + mockGetState.mockReturnValue({ orgContext: 'personal', entries: [], searchQuery: '', selectedIndex: 0 }); + const app = document.getElementById('app')!; + + renderItemList(app); + + expect(app.querySelector('#new-btn')).not.toBeNull(); + }); +}); diff --git a/extension/src/popup/components/__tests__/org-add-form.test.ts b/extension/src/popup/components/__tests__/org-add-form.test.ts new file mode 100644 index 0000000..419264b --- /dev/null +++ b/extension/src/popup/components/__tests__/org-add-form.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../shared/state', () => ({ + getState: vi.fn(() => ({ orgContext: 'personal', orgConfigs: [], orgOffline: false })), + setState: vi.fn(), + sendMessage: vi.fn(), + navigate: vi.fn(), + escapeHtml: (s: string) => s, + popOutToTab: vi.fn(), + isInTab: () => false, + openVaultTab: vi.fn(), +})); + +import { openOrgAddForm } from '../org-add-form'; +import { sendMessage } from '../../../shared/state'; + +const send = sendMessage as ReturnType; + +/** Fill all 7 form fields with the given config values. */ +function fillForm(config: { + orgId: string; displayName: string; hostType: string; + hostUrl: string; repoPath: string; apiToken: string; memberId: string; +}): void { + const f = (name: string) => + document.querySelector(`[name="${name}"]`)!; + (f('orgId') as HTMLInputElement).value = config.orgId; + (f('displayName') as HTMLInputElement).value = config.displayName; + (f('hostType') as HTMLSelectElement).value = config.hostType; + (f('hostUrl') as HTMLInputElement).value = config.hostUrl; + (f('repoPath') as HTMLInputElement).value = config.repoPath; + (f('apiToken') as HTMLInputElement).value = config.apiToken; + (f('memberId') as HTMLInputElement).value = config.memberId; +} + +const SAMPLE_CONFIG = { + orgId: 'acme', + displayName: 'Acme Corp', + hostType: 'gitea', + hostUrl: 'https://git.acme.com', + repoPath: 'acme/vault', + apiToken: 'tok-secret', + memberId: 'alice', +}; + +describe('org-add-form', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + it('renders all 7 fields when opened', () => { + openOrgAddForm(); + + expect(document.querySelector('[name="orgId"]')).toBeTruthy(); + expect(document.querySelector('[name="displayName"]')).toBeTruthy(); + expect(document.querySelector('[name="hostType"]')).toBeTruthy(); + expect(document.querySelector('[name="hostUrl"]')).toBeTruthy(); + expect(document.querySelector('[name="repoPath"]')).toBeTruthy(); + expect(document.querySelector('[name="apiToken"]')).toBeTruthy(); + expect(document.querySelector('[name="memberId"]')).toBeTruthy(); + }); + + it('apiToken field is type=password', () => { + openOrgAddForm(); + const input = document.querySelector('[name="apiToken"]')!; + expect(input.type).toBe('password'); + }); + + it('renders the submit and cancel buttons with stable ids', () => { + openOrgAddForm(); + expect(document.querySelector('#org-add-submit')).toBeTruthy(); + expect(document.querySelector('#org-add-cancel')).toBeTruthy(); + }); + + it('renders an (initially empty) error slot', () => { + openOrgAddForm(); + const slot = document.querySelector('.org-add-error'); + expect(slot).toBeTruthy(); + expect(slot!.textContent).toBe(''); + }); + + it('hostType select has gitea and github options', () => { + openOrgAddForm(); + const sel = document.querySelector('[name="hostType"]')!; + const values = Array.from(sel.options).map((o) => o.value); + expect(values).toContain('gitea'); + expect(values).toContain('github'); + }); + + it('filling fields + clicking Add sends org_add_config with all 7 values', async () => { + send.mockResolvedValueOnce({ ok: true }); + const onAdded = vi.fn(); + + openOrgAddForm(onAdded); + fillForm(SAMPLE_CONFIG); + + const form = document.querySelector('#org-add-form')!; + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await Promise.resolve(); + await Promise.resolve(); + + expect(send).toHaveBeenCalledWith({ + type: 'org_add_config', + config: SAMPLE_CONFIG, + }); + }); + + it('on ok:true removes the overlay and calls onAdded with the orgId', async () => { + send.mockResolvedValueOnce({ ok: true }); + const onAdded = vi.fn(); + + openOrgAddForm(onAdded); + fillForm(SAMPLE_CONFIG); + + const form = document.querySelector('#org-add-form')!; + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await Promise.resolve(); + await Promise.resolve(); + + expect(document.querySelector('#org-add-overlay')).toBeNull(); + expect(onAdded).toHaveBeenCalledWith('acme'); + }); + + it('on ok:false keeps the overlay open and shows error copy in the error slot', async () => { + send.mockResolvedValueOnce({ ok: false, error: 'org_already_configured' }); + + openOrgAddForm(); + fillForm(SAMPLE_CONFIG); + + const form = document.querySelector('#org-add-form')!; + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await Promise.resolve(); + await Promise.resolve(); + + // Overlay must still be in the DOM. + expect(document.querySelector('#org-add-overlay')).toBeTruthy(); + + // The friendly copy for org_already_configured. + const slot = document.querySelector('.org-add-error')!; + expect(slot.textContent).toContain('already'); + }); + + it('Cancel button removes the overlay without calling sendMessage', async () => { + openOrgAddForm(); + + const cancel = document.querySelector('#org-add-cancel')!; + cancel.click(); + await Promise.resolve(); + + expect(document.querySelector('#org-add-overlay')).toBeNull(); + expect(send).not.toHaveBeenCalled(); + }); + + it('clicking the backdrop removes the overlay without calling sendMessage', async () => { + openOrgAddForm(); + + const overlay = document.querySelector('#org-add-overlay')!; + overlay.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + + expect(document.querySelector('#org-add-overlay')).toBeNull(); + expect(send).not.toHaveBeenCalled(); + }); + + it('Esc key removes the overlay without calling sendMessage', async () => { + openOrgAddForm(); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await Promise.resolve(); + + expect(document.querySelector('#org-add-overlay')).toBeNull(); + expect(send).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/popup/components/__tests__/org-switcher.test.ts b/extension/src/popup/components/__tests__/org-switcher.test.ts new file mode 100644 index 0000000..d4fd1d1 --- /dev/null +++ b/extension/src/popup/components/__tests__/org-switcher.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../shared/state', () => ({ + getState: vi.fn(() => ({ orgContext: 'personal', orgConfigs: [], orgOffline: false })), + setState: vi.fn(), + sendMessage: vi.fn(), + navigate: vi.fn(), + escapeHtml: (s: string) => s, + popOutToTab: vi.fn(), + isInTab: () => false, + openVaultTab: vi.fn(), +})); + +import { renderOrgSwitcher } from '../org-switcher'; +import { sendMessage, navigate, getState, setState } from '../../../shared/state'; + +const send = sendMessage as ReturnType; +const nav = navigate as ReturnType; +const mockGetState = getState as ReturnType; +const mockSetState = setState as ReturnType; + +describe('popup/org-switcher', () => { + let host: HTMLElement; + + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = '
'; + host = document.getElementById('host')!; + }); + + // Task 2 — switching contexts. + it('switching to an org sends org_switch and reloads the list', async () => { + send.mockImplementation(async (req: { type: string }) => { + if (req.type === 'org_list_configs') return { ok: true, data: [{ orgId: 'org-1', displayName: 'Acme' }] }; + if (req.type === 'org_switch') return { ok: true, data: { context: 'org-1', offline: false } }; + return { ok: true, data: [] }; + }); + + await renderOrgSwitcher(host); + const sel = host.querySelector('select') as HTMLSelectElement; + sel.value = 'org-1'; + sel.dispatchEvent(new Event('change')); + // Three awaits: org_switch, list load, then org_list_collections fetch. + // navigate('list') fires after all three resolve. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(send).toHaveBeenCalledWith({ type: 'org_switch', context: 'org-1' }); + expect(nav).toHaveBeenCalledWith('list', expect.anything()); + }); + + // loading-design-v2 — switching to an org loads entries into shared state via + // org_list_items, normalizes them (favorite:false, attachment_summaries:[]), + // and calls setState({ entries }) before navigating. Both popup renderItemList + // and vault renderListPane then render state.entries without a separate load. + it('loads org entries into state after switching to an org', async () => { + // First getState call: initial render needs 'personal' so the select shows + // Personal selected. Second call (inside messageForList after setState): + // needs 'org-1' so messageForList() returns { type: 'org_list_items' }. + mockGetState + .mockReturnValueOnce({ orgContext: 'personal', orgConfigs: [], orgOffline: false }) + .mockReturnValueOnce({ orgContext: 'org-1', orgConfigs: [], orgOffline: false }); + + send.mockImplementation(async (req: { type: string }) => { + if (req.type === 'org_list_configs') return { ok: true, data: [{ orgId: 'org-1', displayName: 'Acme' }] }; + if (req.type === 'org_switch') return { ok: true, data: { context: 'org-1', offline: false } }; + if (req.type === 'org_list_items') return { ok: true, data: [ + { id: 'x1', type: 'login', title: 'Org Login', tags: [], collection: 'eng', modified: 1 }, + ]}; + return { ok: true, data: [] }; + }); + + await renderOrgSwitcher(host); + const sel = host.querySelector('select') as HTMLSelectElement; + sel.value = 'org-1'; + sel.dispatchEvent(new Event('change')); + await Promise.resolve(); + await Promise.resolve(); + + expect(send).toHaveBeenCalledWith({ type: 'org_list_items' }); + expect(mockSetState).toHaveBeenCalledWith({ + entries: [['x1', expect.objectContaining({ + title: 'Org Login', favorite: false, attachment_summaries: [], + })]], + }); + }); + + // Task 5 — offline read-only banner. + it('offline org_switch renders the writes-disabled banner', async () => { + send.mockImplementation(async (req: { type: string }) => + req.type === 'org_switch' ? { ok: true, data: { context: 'org-1', offline: true } } + : req.type === 'org_list_configs' ? { ok: true, data: [{ orgId: 'org-1', displayName: 'Acme' }] } + : { ok: true, data: [] }); + + await renderOrgSwitcher(host); + const sel = host.querySelector('select') as HTMLSelectElement; + sel.value = 'org-1'; + sel.dispatchEvent(new Event('change')); + await Promise.resolve(); + + expect(host.textContent).toContain('org offline — writes disabled'); + }); +}); diff --git a/extension/src/popup/components/item-list.ts b/extension/src/popup/components/item-list.ts index ae15f9d..1a3c8cc 100644 --- a/extension/src/popup/components/item-list.ts +++ b/extension/src/popup/components/item-list.ts @@ -3,6 +3,7 @@ /// to the detail view. import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } from '../../shared/state'; +import { currentContext, messageForList, messageForGet, normalizeOrgEntries } from '../../shared/org-context'; import { showToast } from '../../shared/toast'; import { GLYPH_VAULT_TAB, @@ -10,7 +11,7 @@ import { GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP, GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, } from '../../shared/glyphs'; -import type { ItemId, ItemType, ManifestEntry, Item } from '../../shared/types'; +import type { ItemId, ItemType, ManifestEntry, OrgManifestEntry, Item } from '../../shared/types'; /// Extract the display hostname from an icon_hint or fallback to the first tag. function metaLine(e: ManifestEntry): string { @@ -32,6 +33,7 @@ function typeIcon(t: ItemType): string { } } + function buildRowsHtml(): string { const state = getState(); const filtered = getFilteredEntries(); @@ -81,12 +83,13 @@ function wireRowClicks(): void { export function renderItemList(app: HTMLElement): void { const state = getState(); + const isOrg = currentContext() !== 'personal'; app.innerHTML = `
- + ${isOrg ? '' : ''} @@ -98,7 +101,7 @@ export function renderItemList(app: HTMLElement): void {
/ search - + new + ${isOrg ? '' : '+ new'} ↑↓ nav Enter open
@@ -127,10 +130,12 @@ export function renderItemList(app: HTMLElement): void { setState({ loading: true, error: null }); const resp = await sendMessage({ type: 'sync' }); if (resp.ok) { - const listResp = await sendMessage({ type: 'list_items' }); + const listResp = await sendMessage(messageForList()); if (listResp.ok) { - const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; - setState({ entries: data.items, loading: false }); + const entries: Array<[ItemId, ManifestEntry]> = currentContext() === 'personal' + ? (listResp.data as { items: Array<[ItemId, ManifestEntry]> }).items + : normalizeOrgEntries(listResp.data as OrgManifestEntry[]); + setState({ entries, loading: false }); showToast('Synced', 'success'); return; } @@ -159,7 +164,7 @@ export function renderItemList(app: HTMLElement): void { async function openItem(id: ItemId): Promise { setState({ loading: true }); - const resp = await sendMessage({ type: 'get_item', id }); + const resp = await sendMessage(messageForGet(id)); if (resp.ok) { const data = resp.data as { item: Item }; navigate('detail', { @@ -226,7 +231,7 @@ function handleListKeydown(e: KeyboardEvent): void { return; } - if (e.key === '+' && !isSearch) { + if (e.key === '+' && !isSearch && currentContext() === 'personal') { e.preventDefault(); document.removeEventListener('keydown', handleListKeydown); navigate('add'); diff --git a/extension/src/popup/components/org-add-form.ts b/extension/src/popup/components/org-add-form.ts new file mode 100644 index 0000000..d9ee5a2 --- /dev/null +++ b/extension/src/popup/components/org-add-form.ts @@ -0,0 +1,133 @@ +// org-add-form.ts — Modal form for adding a new org config. +// +// Self-contained: appended to document.body so it works in both the popup +// and the vault sidebar without touching either surface's router. +// +// Sends `org_add_config { config: OrgConfig }` on submit and calls the +// optional `onAdded` callback with the new orgId on success. + +import type { OrgConfig } from '../../shared/types'; +import { sendMessage } from '../../shared/state'; +import { lookupErrorCopy } from '../../shared/error-copy'; + +export function openOrgAddForm(onAdded?: (orgId: string) => void): void { + // Build the overlay + form. + const overlay = document.createElement('div'); + overlay.id = 'org-add-overlay'; + overlay.style.cssText = + 'position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;' + + 'align-items:center;justify-content:center;z-index:9999;'; + + const form = document.createElement('form'); + form.id = 'org-add-form'; + form.style.cssText = + 'background:#fff;border-radius:8px;padding:1.5rem;min-width:320px;' + + 'display:flex;flex-direction:column;gap:.75rem;'; + + form.innerHTML = ` +

Add organization

+ + + + + + + + + + + + + + + +
+ +
+ + +
+ `; + + overlay.appendChild(form); + document.body.appendChild(overlay); + + const errorSlot = form.querySelector('.org-add-error') as HTMLElement; + + function close(): void { + overlay.remove(); + } + + // Cancel button. + form.querySelector('#org-add-cancel')!.addEventListener('click', close); + + // Backdrop click. + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(); + }); + + // Esc key. + function onKeydown(e: KeyboardEvent): void { + if (e.key === 'Escape') { + close(); + document.removeEventListener('keydown', onKeydown); + } + } + document.addEventListener('keydown', onKeydown); + + // Clean up the keydown listener when the overlay is removed (cancel/success). + const observer = new MutationObserver(() => { + if (!document.body.contains(overlay)) { + document.removeEventListener('keydown', onKeydown); + observer.disconnect(); + } + }); + observer.observe(document.body, { childList: true }); + + // Submit. + form.addEventListener('submit', async (e) => { + e.preventDefault(); + errorSlot.textContent = ''; + + const data = new FormData(form); + const config: OrgConfig = { + orgId: (data.get('orgId') as string).trim(), + displayName: (data.get('displayName') as string).trim(), + hostType: (data.get('hostType') as 'gitea' | 'github'), + hostUrl: (data.get('hostUrl') as string).trim(), + repoPath: (data.get('repoPath') as string).trim(), + apiToken: (data.get('apiToken') as string).trim(), + memberId: (data.get('memberId') as string).trim(), + }; + + if (!config.orgId) { + errorSlot.textContent = 'Org ID is required'; + return; + } + + const resp = await sendMessage({ type: 'org_add_config', config }); + if (resp.ok) { + close(); + onAdded?.(config.orgId); + } else { + errorSlot.textContent = lookupErrorCopy(resp.error ?? '').body; + } + }); +} diff --git a/extension/src/popup/components/org-switcher.ts b/extension/src/popup/components/org-switcher.ts new file mode 100644 index 0000000..ccba402 --- /dev/null +++ b/extension/src/popup/components/org-switcher.ts @@ -0,0 +1,111 @@ +// Org context switcher + offline banner (Plan B Tasks 2 & 5). +// +// A Personal / . + let optionsHtml = + ``; + for (const org of orgConfigs) { + const sel = currentContext === org.orgId ? ' selected' : ''; + optionsHtml += ``; + } + host.innerHTML = + `` + + ``; + + // 4. Wire the change event. + const select = host.querySelector('select') as HTMLSelectElement; + + const handler = async (_e: Event) => { + const context = select.value; + const switchResp = await sendMessage({ type: 'org_switch', context }); + if (!switchResp.ok) return; + const data = switchResp.data as { context: string; offline: boolean }; + setState({ orgContext: data.context, orgOffline: data.offline }); + + // Offline banner — toggle inside host alongside the select. + const existing = host.querySelector('.org-offline-banner'); + if (data.offline) { + if (!existing) { + const banner = document.createElement('div'); + banner.className = 'org-offline-banner'; + banner.textContent = 'org offline — writes disabled'; + host.appendChild(banner); + } + } else { + existing?.remove(); + } + + // Fire the collection fetch immediately so it overlaps with the list fetch + // (both are independent after org_switch above). We await them sequentially + // below so setState({ entries }) still happens before setState({ orgCollections }). + const colPromise = data.context !== 'personal' + ? sendMessage({ type: 'org_list_collections' }) + : null; + + // messageForList() reads the just-set orgContext so it picks the right SW + // message (list_items for personal, org_list_items for org). Both popup's + // renderItemList and vault's renderListPane read state.entries, so updating + // it here makes both surfaces work correctly on switch. + const listResp = await sendMessage(messageForList()); + if (listResp.ok) { + const entries: Array<[ItemId, ManifestEntry]> = data.context === 'personal' + ? (listResp.data as { items: Array<[ItemId, ManifestEntry]> }).items + : normalizeOrgEntries(listResp.data as OrgManifestEntry[]); + setState({ entries }); + } + + // Fetch (or clear) the collection list for the new context. Any existing + // collection filter is also cleared on personal switch so the next org + // switch starts with no stale filter. + if (data.context === 'personal') { + setState({ orgCollections: [], collectionFilter: undefined }); + } else { + const colResp = colPromise ? await colPromise : null; + if (colResp?.ok) setState({ orgCollections: colResp.data as Collection[] }); + } + + navigate('list', {}); + }; + + select.addEventListener('change', handler); + + // 5. Wire the "Add organization" button. + const addBtn = host.querySelector('.org-add-btn') as HTMLButtonElement; + addBtn.addEventListener('click', () => { + openOrgAddForm((_orgId) => { void renderOrgSwitcher(host); }); + }); + + _cleanupListener = () => select.removeEventListener('change', handler); +} + +export function teardown(): void { + _cleanupListener?.(); + _cleanupListener = null; +} diff --git a/extension/src/popup/components/types/__tests__/detail-read-only.test.ts b/extension/src/popup/components/types/__tests__/detail-read-only.test.ts new file mode 100644 index 0000000..723655f --- /dev/null +++ b/extension/src/popup/components/types/__tests__/detail-read-only.test.ts @@ -0,0 +1,101 @@ +// Regression test: in org context the detail view must NOT render edit or +// trash buttons (Finding #1 from the whole-branch code review). + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock shared/org-context so currentContext() returns a non-personal org. +vi.mock('../../../../shared/org-context', () => ({ + currentContext: vi.fn(() => 'acme-corp'), + messageForGet: vi.fn((id: string) => ({ type: 'org_get_item', id })), + messageForList: vi.fn(() => ({ type: 'org_list_items' })), + normalizeOrgEntries: vi.fn((e: unknown[]) => e), +})); + +vi.mock('../../../../shared/state', async () => { + const navigate = vi.fn(); + const setState = vi.fn(); + const sendMessage = vi.fn(); + const getState = vi.fn(() => ({ + view: 'detail', entries: [], selectedId: 'abc123', selectedItem: null, + selectedIndex: 0, searchQuery: '', activeGroup: null, error: null, + loading: false, capturedTabId: null, capturedUrl: '', newType: null, + orgContext: 'acme-corp', orgOffline: false, + })); + const escapeHtml = (s: string) => + s.replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + return { + navigate, setState, sendMessage, getState, escapeHtml, + popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn(), + }; +}); + +vi.mock('../../../../setup/setup-helpers', () => ({ + scheduleRate: vi.fn(), + STRENGTH_LABELS: {}, + entropyText: vi.fn(() => ''), +})); + +import { renderDetail } from '../login'; +import type { Item } from '../../../../shared/types'; + +const ORG_LOGIN_ITEM: Item = { + id: 'aabbccdd00000001', + title: 'Acme Intranet', + type: 'login', + tags: [], + favorite: false, + created: 0, + modified: 0, + trashed_at: undefined, + notes: undefined, + group: undefined, + core: { + type: 'login', + username: 'alice', + password: 's3cr3t', + url: 'https://intranet.acme.com', + totp: undefined, + }, + sections: [], + attachments: [], + field_history: {}, +}; + +describe('login renderDetail — org context read-only gate', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + // Minimal chrome stub for wireAttachmentsDisclosure + wireFieldHandlers + (globalThis as Record).chrome = { + storage: { + local: { + get: vi.fn((_k: unknown, cb: (v: Record) => void) => cb({})), + set: vi.fn((_o: unknown, cb?: () => void) => cb?.()), + }, + }, + runtime: { sendMessage: vi.fn() }, + }; + }); + + it('renders no edit or trash buttons in org context', async () => { + const app = document.getElementById('app')!; + await renderDetail(app, ORG_LOGIN_ITEM); + + expect(document.getElementById('edit-btn')).toBeNull(); + expect(document.getElementById('trash-btn')).toBeNull(); + }); + + it('keeps the back button (read affordance) in org context', async () => { + const app = document.getElementById('app')!; + await renderDetail(app, ORG_LOGIN_ITEM); + + expect(document.getElementById('back-btn')).not.toBeNull(); + }); + + it('keeps the fill/autofill button (read affordance) in org context', async () => { + const app = document.getElementById('app')!; + await renderDetail(app, ORG_LOGIN_ITEM); + + expect(document.getElementById('fill-btn')).not.toBeNull(); + }); +}); diff --git a/extension/src/popup/components/types/card.ts b/extension/src/popup/components/types/card.ts index 9413011..127ef0b 100644 --- a/extension/src/popup/components/types/card.ts +++ b/extension/src/popup/components/types/card.ts @@ -2,6 +2,7 @@ /// Detail view has a styled card-silhouette signature block. import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state'; +import { currentContext } from '../../../shared/org-context'; import { renderFormHeader } from '../form-header'; import { REQUIRED_PILL_HTML } from '../../../shared/glyphs'; import type { Item, ItemId, ManifestEntry, CardKind, Section, AttachmentRef } from '../../../shared/types'; @@ -58,6 +59,7 @@ function formatExpiry(e: { month: number; year: number } | undefined): string { export async function renderDetail(app: HTMLElement, item: Item): Promise { if (item.core.type !== 'card') return; + const isPersonal = currentContext() === 'personal'; const c = item.core; const number = c.number ?? ''; const brand = brandFromNumber(number); @@ -94,8 +96,8 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise ${renderAttachmentsDisclosure({ itemId: item.id, attachments: item.attachments, mode: 'view' })}
- - + ${isPersonal ? '' : ''} + ${isPersonal ? '' : ''}
`; @@ -127,8 +129,11 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise } switch (e.key) { case 'Escape': teardown(); navigate('list'); break; - case 'e': teardown(); navigate('edit'); break; + case 'e': + if (!isPersonal) break; + teardown(); navigate('edit'); break; case 'd': + if (!isPersonal) break; e.preventDefault(); if (confirm(`Move "${item.title}" to trash?`)) { teardown(); diff --git a/extension/src/popup/components/types/document.ts b/extension/src/popup/components/types/document.ts index a60cf5e..4f05e72 100644 --- a/extension/src/popup/components/types/document.ts +++ b/extension/src/popup/components/types/document.ts @@ -3,6 +3,7 @@ /// Primary attachment is referenced by ID from the item's attachments array. import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state'; +import { currentContext } from '../../../shared/org-context'; import { renderFormHeader } from '../form-header'; import { REQUIRED_PILL_HTML, GLYPH_TYPE_DOCUMENT, GLYPH_PREVIEW } from '../../../shared/glyphs'; import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types'; @@ -264,6 +265,7 @@ async function saveDocument( export async function renderDetail(app: HTMLElement, item: Item): Promise { teardown(); if (item.core.type !== 'document') return; + const isPersonal = currentContext() === 'personal'; const c = item.core; const primaryRef = item.attachments.find((a) => a.id === c.primary_attachment); if (!primaryRef) { @@ -301,8 +303,8 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise
- - + ${isPersonal ? '' : ''} + ${isPersonal ? '' : ''}
`; @@ -381,8 +383,11 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise } switch (e.key) { case 'Escape': teardown(); navigate('list'); break; - case 'e': teardown(); navigate('edit'); break; + case 'e': + if (!isPersonal) break; + teardown(); navigate('edit'); break; case 'd': + if (!isPersonal) break; e.preventDefault(); if (confirm(`Move "${item.title}" to trash?`)) { teardown(); diff --git a/extension/src/popup/components/types/identity.ts b/extension/src/popup/components/types/identity.ts index f9776c1..6bc8a33 100644 --- a/extension/src/popup/components/types/identity.ts +++ b/extension/src/popup/components/types/identity.ts @@ -2,6 +2,7 @@ /// Detail view shows a "profile card" signature block + plain rows. import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state'; +import { currentContext } from '../../../shared/org-context'; import { renderFormHeader } from '../form-header'; import { REQUIRED_PILL_HTML } from '../../../shared/glyphs'; import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types'; @@ -48,6 +49,7 @@ function formatDate(iso: string | undefined): string { export async function renderDetail(app: HTMLElement, item: Item): Promise { if (item.core.type !== 'identity') return; + const isPersonal = currentContext() === 'personal'; const c = item.core; const sigInner = `
@@ -72,8 +74,8 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise ${renderAttachmentsDisclosure({ itemId: item.id, attachments: item.attachments, mode: 'view' })}
- - + ${isPersonal ? '' : ''} + ${isPersonal ? '' : ''}
`; @@ -103,8 +105,11 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise } switch (e.key) { case 'Escape': teardown(); navigate('list'); break; - case 'e': teardown(); navigate('edit'); break; + case 'e': + if (!isPersonal) break; + teardown(); navigate('edit'); break; case 'd': + if (!isPersonal) break; e.preventDefault(); if (confirm(`Move "${item.title}" to trash?`)) { teardown(); diff --git a/extension/src/popup/components/types/key.ts b/extension/src/popup/components/types/key.ts index 36cb3ba..d62b2b6 100644 --- a/extension/src/popup/components/types/key.ts +++ b/extension/src/popup/components/types/key.ts @@ -3,6 +3,7 @@ /// since