From d8ce0f3dddeb8c1698dcf8d4ecd0cd1a487b76da Mon Sep 17 00:00:00 2001 From: mashfromband Date: Sun, 11 Oct 2026 18:27:52 +0900 Subject: [PATCH 01/10] feat(cli): read shared encrypted items and attachments --- crates/op-cli/src/encrypted.rs | 494 +++++++++++++++- crates/op-cli/src/encrypted_cli_tests.rs | 3 + crates/op-cli/src/main.rs | 34 +- crates/op-cli/src/recipient_cli_tests.rs | 354 +++++++++++ documents/runbooks/op-cli.md | 26 +- documents/spec/encrypted-cli.md | 121 +++- .../src/cli_recipient.rs | 557 ++++++++++++++++++ .../src/sharing_rotation.rs | 4 + 8 files changed, 1580 insertions(+), 13 deletions(-) create mode 100644 crates/op-cli/src/recipient_cli_tests.rs create mode 100644 tools/encrypted-storage-acceptance/src/cli_recipient.rs diff --git a/crates/op-cli/src/encrypted.rs b/crates/op-cli/src/encrypted.rs index 5aae1d19..6d7a684a 100644 --- a/crates/op-cli/src/encrypted.rs +++ b/crates/op-cli/src/encrypted.rs @@ -1,4 +1,4 @@ -//! Owner-only encrypted CLI. Session identity and revisioned box metadata are +//! Encrypted CLI. Session identity and revisioned box metadata are //! authoritative inputs; neither an opaque bundle nor a replay supplies trust. use std::fmt; use std::io::{IsTerminal, Read}; @@ -6,10 +6,17 @@ use std::time::Duration; use passshare_connect::{ BoxState, EncryptedItem, EncryptedSessionClient, EncryptedSessionError, ItemContext, - OwnerKeyContext, SharingItemSummary, SharingRotationClient, SharingSnapshot, + OwnerKeyContext, SharedKeyExpectation, SharingItemSummary, SharingRotationClient, + SharingSnapshot, }; use passshare_core::{ - cloud_crypto::PasswordBoxKey, crypto, storage::file_format::KdfParams, ConnectId, + cloud_crypto::{ + AccountIdentity, AccountIdentityContext, AttachmentContext, PasswordBoxKey, + PublicAccountIdentity, SharedKeyContext, + }, + crypto, + storage::file_format::KdfParams, + ConnectId, }; use reqwest::{ header::{HeaderValue, AUTHORIZATION}, @@ -21,7 +28,7 @@ use unicode_normalization::UnicodeNormalization; use uuid::Uuid; use zeroize::{Zeroize, Zeroizing}; -use super::{Cli, Commands, ItemCommands, VaultCommands}; +use super::{AttachmentCommands, Cli, Commands, ItemCommands, VaultCommands}; // A maximal Core envelope expands to about 1.4 MiB in JSON/Base64. Allow // bounded replay input as well as the original one-MiB plaintext DTO. @@ -42,7 +49,9 @@ impl fmt::Display for Error { Some(401) => { f.write_str("HTTP 401: session expired or invalid; authenticate again") } - Some(403) => f.write_str("HTTP 403: current owner permission is required"), + Some(403) => { + f.write_str("HTTP 403: current permission or shared key grant is required") + } Some(409) => f.write_str( "HTTP 409: item, key epoch or box metadata changed; no automatic retry", ), @@ -105,6 +114,7 @@ fn parse_input(bytes: &[u8]) -> Result { "item", "selectedFields", "preparedWrite", + "sharedReceive", ], ) { return Err(INPUT); @@ -708,6 +718,427 @@ fn create_item(input: &Plaintext, box_id: &str) -> Result<Plaintext> { Ok(item) } +/// Only caller-held trust and versions enter this structure. JSON DTOs are +/// preserved in full; strict allowlists apply to these control inputs. +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SharedReceive { + sender: PublicAccountIdentity, + recipient: PublicAccountIdentity, + context: SharedKeyContext, + identity_bundle_version: u64, + #[serde(default)] + item_context: Option<ItemContext>, + #[serde(default)] + attachment_context: Option<AttachmentContext>, +} + +fn trust_input(input: &Plaintext, box_id: &str) -> Result<SharedReceive> { + if !object_keys( + &input.0, + &["sessionToken", "encryptionPassword", "sharedReceive"], + ) { + return Err(INPUT); + } + let value = input + .0 + .get("sharedReceive") + .filter(|v| v.is_object()) + .ok_or(INPUT)?; + for name in [ + "sender", + "recipient", + "context", + "itemContext", + "attachmentContext", + ] { + if value.get(name).is_some_and(|v| !v.is_object()) { + return Err(INPUT); + } + } + // serde UUID parsing accepts aliases. Trust supplied on this boundary must + // retain the canonical public account spelling, not silently normalize it. + for path in [ + "/sender/accountId", + "/recipient/accountId", + "/context/senderAccountId", + "/context/recipientAccountId", + ] { + let raw = value.pointer(path).and_then(Value::as_str).ok_or(INPUT)?; + let id = Uuid::parse_str(raw).map_err(|_| INPUT)?; + if id.is_nil() || id.to_string() != raw { + return Err(INPUT); + } + } + let trust: SharedReceive = serde_json::from_value(value.clone()).map_err(|_| INPUT)?; + if !(1..=MAX_SAFE).contains(&trust.identity_bundle_version) { + return Err(INPUT); + } + trust.expectation().validate(box_id)?; + if let Some(c) = &trust.item_context { + if c.password_box_id.as_str() != box_id + || c.key_epoch != trust.context.key_epoch + || !(1..=MAX_SAFE).contains(&c.item_version) + { + return Err(INPUT); + } + } + if let Some(c) = &trust.attachment_context { + if c.password_box_id.as_str() != box_id + || c.key_epoch != trust.context.key_epoch + || trust + .item_context + .as_ref() + .is_none_or(|i| i.item_id != c.item_id) + { + return Err(INPUT); + } + } + Ok(trust) +} +impl SharedReceive { + fn expectation(&self) -> SharedKeyExpectation { + SharedKeyExpectation { + context: self.context.clone(), + sender: self.sender.clone(), + recipient: self.recipient.clone(), + } + } + fn item(&self, id: &str) -> Result<&ItemContext> { + self.item_context + .as_ref() + .filter(|c| c.item_id.as_str() == id) + .ok_or(INPUT) + } +} + +struct RecipientSession { + sdk: EncryptedSessionClient, + box_route: ConnectId, + key: PasswordBoxKey, + trust: SharedReceive, +} +impl RecipientSession { + async fn open( + host: &str, + token: &str, + password: &str, + box_id: &str, + trust: SharedReceive, + ) -> Result<Self> { + let box_route = box_id.parse().map_err(|_| INPUT)?; + let sdk = EncryptedSessionClient::new(host, token)?; + let subject_id = live_subject(host, token).await?; + if subject_id != trust.recipient.account_id { + return Err(CONTEXT); + } + if sdk.state(box_id).await? + != (BoxState::Encrypted { + key_epoch: trust.context.key_epoch, + }) + { + return Err(CONTEXT); + } + let identity_context = AccountIdentityContext { + account_id: trust.recipient.account_id, + identity_version: trust.recipient.identity_version, + }; + let record = sdk + .get_account_identity(&identity_context, &trust.recipient.key_id) + .await?; + if record.identity != trust.recipient + || record.bundle_version != trust.identity_bundle_version + { + return Err(CONTEXT); + } + let salt = KdfParams { + algorithm: "argon2id".into(), + salt: record.bundle.salt, + memory_kib: crypto::KDF_MEMORY_KIB, + time_cost: crypto::KDF_TIME_COST, + parallelism: crypto::KDF_PARALLELISM, + } + .salt_bytes() + .map_err(|_| CRYPTO)?; + let mut normalized = Zeroizing::new(String::with_capacity( + password.nfc().map(char::len_utf8).sum(), + )); + normalized.extend(password.nfc()); + let root = crypto::derive_master_key_with_params(&normalized, &salt, 65_536, 3, 4) + .map_err(|_| CRYPTO)?; + let identity = AccountIdentity::unwrap( + &record.bundle.wrapped, + &root, + &identity_context, + &trust.recipient.key_id, + ) + .map_err(|_| CRYPTO)?; + if identity.public_identity() != trust.recipient { + return Err(CONTEXT); + } + let expected = trust.expectation(); + let grant = SharingRotationClient::new(&sdk) + .get_self_grant(box_id, &expected) + .await?; + let key = PasswordBoxKey::unwrap_for_recipient( + &grant.envelope, + &identity, + &trust.sender, + &trust.context, + ) + .map_err(|_| CRYPTO)?; + Ok(Self { + sdk, + box_route, + key, + trust, + }) + } + async fn item_at(&self, revision: u64, expected: &ItemContext) -> Result<Plaintext> { + let envelope = self.sdk.get_attachment_snapshot(revision, expected).await?; + Ok(Plaintext( + self.key + .decrypt_item(&envelope, expected) + .map_err(|_| CRYPTO)?, + )) + } + async fn get(&self, id: &str) -> Result<Plaintext> { + let expected = self.trust.item(id)?; + let state = self + .sdk + .get_attachment_state(self.box_route.as_str(), self.trust.context.key_epoch) + .await?; + self.item_at(state.state_revision, expected).await + } + async fn download( + &self, + item: &str, + attachment: &str, + out: &std::path::Path, + print_name: bool, + ) -> Result<()> { + let expected_item = self.trust.item(item)?; + let expected = self + .trust + .attachment_context + .as_ref() + .filter(|c| c.attachment_id.as_str() == attachment) + .ok_or(INPUT)?; + let state = self + .sdk + .get_attachment_state(self.box_route.as_str(), self.trust.context.key_epoch) + .await?; + let dto = self.item_at(state.state_revision, expected_item).await?; + authenticated_reference(&dto.0, expected)?; + let envelope = self + .sdk + .get_attachment(state.state_revision, expected) + .await?; + let file = self + .key + .decrypt_attachment(&envelope, expected) + .map_err(|_| CRYPTO)?; + // Never interpret the encrypted name as a path. The user supplies the + // only destination, and create_new is atomic against files and symlinks. + use std::io::Write; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut destination = options + .open(out) + .map_err(|_| Error::Invalid("cannot create output file; choose a new explicit path"))?; + destination + .write_all(file.content()) + .and_then(|_| destination.sync_all()) + .map_err(|_| { + Error::Invalid("attachment output failed; the new file may be incomplete") + })?; + if print_name { + println!( + "{}", + Zeroizing::new(serde_json::to_string(file.name()).map_err(|_| CRYPTO)?).as_str() + ); + } + Ok(()) + } +} + +fn authenticated_reference(dto: &Value, expected: &AttachmentContext) -> Result<()> { + let references = dto + .get("attachments") + .and_then(Value::as_array) + .ok_or(CRYPTO)?; + let mut ids = std::collections::HashSet::new(); + let mut found = false; + for reference in references { + if !reference.is_object() + || reference.as_object().unwrap().len() != 3 + || !object_keys( + reference, + &["attachmentId", "attachmentVersion", "keyEpoch"], + ) + { + return Err(CRYPTO); + } + let id = reference["attachmentId"].as_str().ok_or(CRYPTO)?; + ConnectId::validate(id).map_err(|_| CRYPTO)?; + let version = reference["attachmentVersion"] + .as_u64() + .filter(|v| (1..=MAX_SAFE).contains(v)) + .ok_or(CRYPTO)?; + let epoch = reference["keyEpoch"] + .as_u64() + .filter(|v| (1..=MAX_SAFE).contains(v)) + .ok_or(CRYPTO)?; + if !ids.insert(id) || epoch != expected.key_epoch { + return Err(CRYPTO); + } + if id == expected.attachment_id.as_str() { + if version != expected.attachment_version { + return Err(CRYPTO); + } + found = true; + } + } + if !found { + return Err(CRYPTO); + } + Ok(()) +} + +async fn run_recipient(cli: Cli, mut input: Plaintext) -> Result<()> { + if !cli.secret_stdin || cli.prepare_only { + return Err(INPUT); + } + let box_id = match &cli.command { + Commands::Item { command: ItemCommands::List { vault } | ItemCommands::Get { vault, .. } } => vault.clone(), + Commands::Read { reference } => passshare_connect::parse_op_reference(reference).ok_or(INPUT)?.vault, + Commands::Attachment { command: AttachmentCommands::Get { vault, .. } } => vault.clone(), + _ => return Err(Error::Invalid("recipient mode supports only item list/get, read and attachment get; writes, rotation, migration and recovery are unsupported")), + }; + let trust = trust_input(&input, &box_id)?; + // Validate all operation context before sending even the session GET. + match &cli.command { + Commands::Item { + command: ItemCommands::Get { item, .. }, + } => { + trust.item(item)?; + if trust.attachment_context.is_some() { + return Err(INPUT); + } + } + Commands::Read { reference } => { + trust.item( + &passshare_connect::parse_op_reference(reference) + .ok_or(INPUT)? + .item, + )?; + if trust.attachment_context.is_some() { + return Err(INPUT); + } + } + Commands::Attachment { + command: + AttachmentCommands::Get { + item, + attachment, + out, + .. + }, + } => { + trust.item(item)?; + if out.as_os_str().is_empty() + || trust + .attachment_context + .as_ref() + .is_none_or(|c| c.attachment_id.as_str() != attachment) + { + return Err(INPUT); + } + } + _ if trust.item_context.is_some() || trust.attachment_context.is_some() => { + return Err(INPUT) + } + _ => {} + } + let token = secret(&input, "sessionToken")?; + let password = secret(&input, "encryptionPassword")?; + let host = cli + .passshare_host + .or_else(|| std::env::var("PASSSHARE_HOST").ok()) + .ok_or(INPUT)?; + let session = RecipientSession::open(&host, &token, &password, &box_id, trust).await?; + drop(token); + drop(password); + erase(&mut input.0); + match cli.command { + Commands::Item { + command: ItemCommands::List { .. }, + } => { + let mut cursor = None; + let mut items = Vec::new(); + loop { + let page = session.sdk.list(&box_id, cursor.as_deref()).await?; + if page + .items + .iter() + .any(|i| i.key_epoch != session.trust.context.key_epoch) + { + return Err(CONTEXT); + } + items.extend(page.items); + cursor = page.next_cursor.map(|c| c.to_string()); + if cursor.is_none() { + break; + } + } + // Failures must not leave a partially successful listing on stdout. + for i in items { + println!("{}\t{}", i.item_id, i.item_version); + } + } + Commands::Item { + command: ItemCommands::Get { item, fields, .. }, + } => { + let dto = session.get(&item).await?; + if let Some(name) = fields { + println!("{}", selected_value(&dto.0, &name)?); + } else { + println!( + "{}", + Zeroizing::new(serde_json::to_string_pretty(&dto.0).map_err(|_| INPUT)?) + .as_str() + ); + } + } + Commands::Read { reference } => { + let r = passshare_connect::parse_op_reference(&reference).ok_or(INPUT)?; + let dto = session.get(&r.item).await?; + println!("{}", selected_value(&dto.0, &r.field)?); + } + Commands::Attachment { + command: + AttachmentCommands::Get { + item, + attachment, + out, + print_name, + .. + }, + } => { + session + .download(&item, &attachment, &out, print_name) + .await? + } + _ => return Err(INPUT), + } + Ok(()) +} + pub(super) async fn run(cli: Cli) -> Result<()> { // Reject argv secret assignments before reading credentials or making HTTP calls. match &cli.command { @@ -716,6 +1147,15 @@ pub(super) async fn run(cli: Cli) -> Result<()> { _ => {}, } let mut input = read_input(cli.secret_stdin)?; + if cli.recipient { + return run_recipient(cli, input).await; + } + if input.0.get("sharedReceive").is_some() || matches!(cli.command, Commands::Attachment { .. }) + { + return Err(Error::Invalid( + "shared recipient reads require --recipient --secret-stdin", + )); + } let token = secret(&input, "sessionToken")?; let password = secret(&input, "encryptionPassword")?; let host = cli @@ -731,6 +1171,7 @@ pub(super) async fn run(cli: Cli) -> Result<()> { | ItemCommands::Edit { vault, .. } | ItemCommands::Delete { vault, .. } | ItemCommands::Retry { vault } => vault.clone(), }, Commands::Read { reference } => passshare_connect::parse_op_reference(reference).ok_or(INPUT)?.vault, + Commands::Attachment { .. } => return Err(INPUT), }; let session = Session::open(&host, &token, &password, &box_id).await?; // Keys and credentials are kept only in memory for this invocation. @@ -743,6 +1184,7 @@ pub(super) async fn run(cli: Cli) -> Result<()> { erase(s); } let prepared = match cli.command { + Commands::Attachment { .. } => return Err(INPUT), Commands::Vault { command: VaultCommands::List, } => { @@ -841,6 +1283,48 @@ pub(super) async fn run(cli: Cli) -> Result<()> { mod tests { use super::*; + #[test] + fn attachment_reference_requires_exact_authenticated_versions_and_unique_ordered_ids() { + let expected = AttachmentContext { + password_box_id: "aaaaaaaaaaaaaaaaaaaaaaaaaa".parse().unwrap(), + item_id: "bbbbbbbbbbbbbbbbbbbbbbbbbb".parse().unwrap(), + attachment_id: "cccccccccccccccccccccccccc".parse().unwrap(), + attachment_version: 2, + key_epoch: 3, + }; + let first = + json!({"attachmentId":"dddddddddddddddddddddddddd","attachmentVersion":1,"keyEpoch":3}); + let selected = + json!({"attachmentId":expected.attachment_id,"attachmentVersion":2,"keyEpoch":3}); + let valid = + json!({"attachments":[first,selected],"unknown":{"ordered":[3,1,2],"null":null}}); + authenticated_reference(&valid, &expected).unwrap(); + assert!(valid["attachments"][1] == selected); + for (name, value) in [ + ("attachmentId", json!(null)), + ("attachmentVersion", json!(0)), + ("attachmentVersion", json!(1)), + ("attachmentVersion", json!(2.0)), + ("attachmentVersion", json!(MAX_SAFE + 1)), + ("keyEpoch", json!(2)), + ("keyEpoch", json!(MAX_SAFE + 1)), + ("unknown", json!(false)), + ] { + let mut bad = valid.clone(); + bad["attachments"][1][name] = value; + assert!(authenticated_reference(&bad, &expected).is_err()); + } + for bad in [ + json!({"attachments":null}), + json!({"attachments":[]}), + json!({"attachments":[selected,selected]}), + json!({"attachments":[first]}), + json!({"attachments":[selected,{"attachmentId":"dddddddddddddddddddddddddd","attachmentVersion":1,"keyEpoch":2}]}), + ] { + assert!(authenticated_reference(&bad, &expected).is_err()); + } + } + #[test] fn raw_duplicate_keys_and_escaped_aliases_are_rejected() { for raw in [ diff --git a/crates/op-cli/src/encrypted_cli_tests.rs b/crates/op-cli/src/encrypted_cli_tests.rs index 9c0c3d8c..c9806e7f 100644 --- a/crates/op-cli/src/encrypted_cli_tests.rs +++ b/crates/op-cli/src/encrypted_cli_tests.rs @@ -16,6 +16,9 @@ use passshare_core::{cloud_crypto::PasswordBoxKey, crypto}; use serde_json::{json, Value}; use zeroize::Zeroizing; +#[path = "recipient_cli_tests.rs"] +mod recipient; + const BOX: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaa"; const OWNER: &str = "11111111-1111-4111-8111-111111111111"; const TOKEN: &str = "fixture-session-only-marker"; diff --git a/crates/op-cli/src/main.rs b/crates/op-cli/src/main.rs index c4f5c208..44af81a9 100644 --- a/crates/op-cli/src/main.rs +++ b/crates/op-cli/src/main.rs @@ -10,9 +10,12 @@ mod encrypted; #[derive(Parser)] #[command(name = "op", about = "PassShare 1Password Connect compatible CLI")] struct Cli { - /// Use the live PassShare session API for an owner-operated encrypted password box. + /// Use the live PassShare session API for an encrypted password box. #[arg(long, global = true)] encrypted: bool, + /// Read as a shared recipient using independently verified trust inputs on stdin. + #[arg(long, global = true, requires_all = ["encrypted", "secret_stdin"])] + recipient: bool, /// Read session credentials and secret operation input as JSON from a pipe. #[arg(long, global = true)] secret_stdin: bool, @@ -43,6 +46,27 @@ enum Commands { }, /// Read op:// secret reference Read { reference: String }, + /// Download an authenticated encrypted attachment to an explicit new file. + Attachment { + #[command(subcommand)] + command: AttachmentCommands, + }, +} + +#[derive(Subcommand)] +enum AttachmentCommands { + Get { + attachment: String, + #[arg(long)] + item: String, + #[arg(long)] + vault: String, + #[arg(long)] + out: std::path::PathBuf, + /// Print the authenticated original name as a JSON string after saving. + #[arg(long)] + print_name: bool, + }, } #[derive(Subcommand)] @@ -111,7 +135,9 @@ async fn main() -> std::process::ExitCode { let _ = error.print(); return std::process::ExitCode::SUCCESS; } - if encrypted_requested || std::env::args().any(|arg| arg.starts_with("--secret-stdin")) + if encrypted_requested + || std::env::args() + .any(|arg| arg.starts_with("--secret-stdin") || arg.starts_with("--recipient")) { eprintln!("invalid encrypted command options; use --help (secret input is never an argument)"); } else { @@ -145,12 +171,16 @@ async fn run_connect(cli: Cli) -> Result<(), Box<dyn std::error::Error>> { || cli.passshare_host.is_some() || cli.password_box.is_some() || cli.prepare_only + || cli.recipient { return Err("PassShare encrypted options require --encrypted".into()); } let client = ConnectClient::from_env()?; match cli.command { + Commands::Attachment { .. } => { + return Err("attachment get requires --encrypted --recipient".into()) + } Commands::Vault { command } => match command { VaultCommands::List => { let vaults = client.list_vaults().await?; diff --git a/crates/op-cli/src/recipient_cli_tests.rs b/crates/op-cli/src/recipient_cli_tests.rs new file mode 100644 index 00000000..ebab12c8 --- /dev/null +++ b/crates/op-cli/src/recipient_cli_tests.rs @@ -0,0 +1,354 @@ +//! Finite real-binary recipient transport and trust-boundary regressions. +use super::*; +use passshare_connect::AccountIdentityBundle; +use passshare_core::cloud_crypto::{ + AccountIdentity, AccountIdentityContext, ItemContext, SharedKeyContext, +}; + +const RECIPIENT: &str = "22222222-2222-4222-8222-222222222222"; +struct Fixture { + input: Value, + replies: Vec<Value>, + dto: Value, +} +impl Fixture { + fn new() -> Self { + let identity = |id: &str| { + AccountIdentity::generate(AccountIdentityContext { + account_id: id.parse().unwrap(), + identity_version: 1, + }) + .unwrap() + }; + let sender = identity(OWNER); + let recipient = identity(RECIPIENT); + let s = sender.public_identity(); + let r = recipient.public_identity(); + let context = SharedKeyContext { + password_box_id: BOX.parse().unwrap(), + sender_account_id: s.account_id, + recipient_account_id: r.account_id, + key_epoch: 1, + sender_identity_version: 1, + recipient_identity_version: 1, + sender_key_id: s.key_id.clone(), + recipient_key_id: r.key_id.clone(), + }; + let password = random_password(); + let salt = crypto::generate_salt().unwrap(); + let master = crypto::derive_master_key(&password, &salt).unwrap(); + let bundle = + AccountIdentityBundle::new(STANDARD.encode(salt), recipient.wrap(&master).unwrap()) + .unwrap(); + let key = PasswordBoxKey::generate().unwrap(); + let item_context = ItemContext { + password_box_id: BOX.parse().unwrap(), + item_id: LEGACY_ITEM.parse().unwrap(), + item_version: 3, + key_epoch: 1, + }; + let dto = json!({"id":LEGACY_ITEM,"vault":{"id":BOX},"version":3,"category":"CUSTOM","title":"visible", + "fields":[{"id":"second","value":"retained","unknown":[3,1,2]},{"id":"first","purpose":"PASSWORD","value":FIELD}], + "tags":["z","a","z"],"urls":[{"href":"https://example.test/"}],"unknown":{"nested":[null,true,"\0Ж"]}}); + let envelope = key.encrypt_item(&dto, &item_context).unwrap(); + let grant = key.wrap_for_recipient(&sender, &r, &context).unwrap(); + Self { + input: json!({"sessionToken":TOKEN,"encryptionPassword":password.as_str(),"sharedReceive":{ + "sender":s,"recipient":r,"context":context,"identityBundleVersion":1,"itemContext":item_context}}), + replies: vec![ + json!({"user":{"id":RECIPIENT}}), + json!({"mode":"encrypted","keyEpoch":1}), + json!({"bundleVersion":1,"identity":r,"bundle":bundle}), + json!({"grantVersion":1,"keyEpoch":1,"envelope":grant}), + json!({"stateRevision":7,"keyEpoch":1}), + json!(envelope), + ], + dto, + } + } + fn run(&self, args: &[&str], failure: Option<(usize, u16)>) -> (Output, Vec<String>) { + self.raw( + args, + failure, + Zeroizing::new(self.input.to_string()).as_str(), + ) + } + fn raw( + &self, + args: &[&str], + failure: Option<(usize, u16)>, + raw: &str, + ) -> (Output, Vec<String>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let host = format!("http://{}", listener.local_addr().unwrap()); + listener.set_nonblocking(true).unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let child_stop = stop.clone(); + let replies = self.replies.clone(); + let worker = thread::spawn(move || { + let mut paths = Vec::new(); + while !child_stop.load(Ordering::SeqCst) { + match listener.accept() { + Ok((mut stream, _)) => { + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut header = Zeroizing::new(Vec::new()); + while !header.ends_with(b"\r\n\r\n") { + assert!(header.len() < 32768); + let mut byte = [0]; + stream.read_exact(&mut byte).unwrap(); + header.push(byte[0]); + } + let header = Zeroizing::new(String::from_utf8(header.to_vec()).unwrap()); + let path = header.lines().next().unwrap().to_owned(); + assert!(path.starts_with("GET ")); + let index = paths.len(); + paths.push(path); + let status = failure.filter(|(i, _)| *i == index).map_or(200, |(_, s)| s); + let body = if status == 200 { + replies.get(index).unwrap().to_string() + } else { + json!({"error":{"code":"PS016","message":FIELD}}).to_string() + }; + write!(stream,"HTTP/1.1 {status} status\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",body.len()).unwrap(); + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(2)) + } + Err(_) => break, + } + } + paths + }); + let mut child = Command::new(env!("CARGO_BIN_EXE_op")) + .env_clear() + .env( + "SystemRoot", + std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into()), + ) + .env("OP_CONNECT_HOST", "http://127.0.0.1:1") + .env("OP_CONNECT_TOKEN", "forbidden-connect-marker") + .args([ + "--encrypted", + "--recipient", + "--secret-stdin", + "--passshare-host", + &host, + ]) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let write = child.stdin.take().unwrap().write_all(raw.as_bytes()); + assert!(write.is_ok() || write.unwrap_err().kind() == std::io::ErrorKind::BrokenPipe); + let out = child.stdout.take().unwrap(); + let err = child.stderr.take().unwrap(); + let out = thread::spawn(move || read_bounded(out)); + let err = thread::spawn(move || read_bounded(err)); + let deadline = Instant::now() + Duration::from_secs(45); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("recipient CLI deadline"); + } + thread::sleep(Duration::from_millis(5)); + }; + stop.store(true, Ordering::SeqCst); + let paths = worker.join().unwrap(); + let output = Output { + status, + stdout: out.join().unwrap(), + stderr: err.join().unwrap(), + }; + for secret in [ + TOKEN, + self.input["encryptionPassword"].as_str().unwrap(), + "forbidden-connect-marker", + ] { + assert!(!String::from_utf8_lossy(&output.stdout).contains(secret)); + assert!(!String::from_utf8_lossy(&output.stderr).contains(secret)); + } + if !output.status.success() { + assert!(output.stdout.is_empty()); + assert!(!String::from_utf8_lossy(&output.stderr).contains(FIELD)); + } + (output, paths) + } +} +impl Drop for Fixture { + fn drop(&mut self) { + fn wipe(v: &mut Value) { + match v { + Value::String(s) => zeroize::Zeroize::zeroize(s), + Value::Array(a) => a.iter_mut().for_each(wipe), + Value::Object(o) => o.values_mut().for_each(wipe), + _ => {} + } + } + wipe(&mut self.input); + wipe(&mut self.dto); + } +} + +#[test] +fn recipient_reads_complete_dto_selected_field_reference_and_public_list() { + let mut f = Fixture::new(); + let args = ["item", "get", LEGACY_ITEM, "--vault", BOX]; + let (out, paths) = f.run(&args, None); + assert!(out.status.success()); + assert!(serde_json::from_slice::<Value>(&out.stdout).unwrap() == f.dto); + assert_eq!(paths.len(), 6); + assert!(paths[3].contains("/grants/me ")); + assert!(paths[4].contains("/attachment-state ")); + assert!(paths[5].contains("/attachment-snapshot?revision=7 ")); + assert!(paths + .iter() + .all(|p| !p.contains("sharing-snapshot") && !p.contains("/v1/"))); + let (out, _) = f.run( + &[ + "item", + "get", + LEGACY_ITEM, + "--vault", + BOX, + "--fields", + "password", + ], + None, + ); + assert!(out.status.success()); + assert!(out.stdout == format!("{FIELD}\n").as_bytes()); + let reference = format!("op://{BOX}/{LEGACY_ITEM}/password"); + let (out, _) = f.run(&["read", &reference], None); + assert!(out.status.success()); + assert!(out.stdout == format!("{FIELD}\n").as_bytes()); + f.input["sharedReceive"] + .as_object_mut() + .unwrap() + .remove("itemContext"); + f.replies[4] = + json!({"items":[{"itemId":LEGACY_ITEM,"itemVersion":3,"keyEpoch":1}],"nextCursor":null}); + let (out, paths) = f.run(&["item", "list", "--vault", BOX], None); + assert!(out.status.success()); + assert!(out.stdout == format!("{LEGACY_ITEM}\t3\n").as_bytes()); + assert_eq!(paths.len(), 5); +} + +#[test] +fn recipient_denials_are_terminal_without_retry_or_plaintext_output() { + let f = Fixture::new(); + for (index, status) in [(0, 401), (1, 403), (2, 401), (3, 403), (4, 409), (5, 409)] { + let (out, paths) = f.run( + &["item", "get", LEGACY_ITEM, "--vault", BOX], + Some((index, status)), + ); + assert!(!out.status.success()); + assert_eq!(paths.len(), index + 1); + assert!(String::from_utf8_lossy(&out.stderr).contains(&format!("HTTP {status}"))); + } +} + +#[test] +fn recipient_rejects_missing_alias_duplicate_unknown_null_and_unsafe_trust_before_http() { + let mut f = Fixture::new(); + let valid = f.input.clone(); + let args = ["item", "get", LEGACY_ITEM, "--vault", BOX]; + // First establish that the injected transport is used for a valid command. + assert!(f.run(&args, None).0.status.success()); + for (path, value) in [ + ("/sharedReceive/context/keyEpoch", json!(0)), + ( + "/sharedReceive/context/keyEpoch", + json!(9007199254740992u64), + ), + ("/sharedReceive/context/keyEpoch", json!(1.5)), + ("/sharedReceive/itemContext", Value::Null), + ("/sharedReceive/sender", Value::Null), + ( + "/sharedReceive/recipient/accountId", + json!("{22222222-2222-4222-8222-222222222222}"), + ), + ("/sharedReceive/identityBundleVersion", json!(0)), + ( + "/sharedReceive/identityBundleVersion", + json!(9007199254740992u64), + ), + ] { + f.input = valid.clone(); + *f.input.pointer_mut(path).unwrap() = value; + let (out, paths) = f.run(&args, None); + assert!(!out.status.success()); + assert!(paths.is_empty()); + } + for name in [ + "sender", + "recipient", + "context", + "identityBundleVersion", + "itemContext", + ] { + f.input = valid.clone(); + f.input["sharedReceive"] + .as_object_mut() + .unwrap() + .remove(name); + let (out, paths) = f.run(&args, None); + assert!(!out.status.success()); + assert!(paths.is_empty()); + } + f.input = valid.clone(); + f.input["sharedReceive"]["unknown"] = json!(FIELD); + assert!(f.run(&args, None).1.is_empty()); + f.input = valid; + let raw = Zeroizing::new(f.input.to_string().replacen( + "\"identityBundleVersion\":1", + "\"identityBundleVersion\":1,\"identityBundleVersion\":1", + 1, + )); + let (out, paths) = f.raw(&args, None, &raw); + assert!(!out.status.success()); + assert!(paths.is_empty()); + for args in [ + vec!["item", "delete", LEGACY_ITEM, "--vault", BOX], + vec!["item", "retry", "--vault", BOX], + vec!["vault", "list"], + vec!["--prepare-only", "item", "get", LEGACY_ITEM, "--vault", BOX], + ] { + let (out, paths) = f.run(&args, None); + assert!(!out.status.success()); + assert!(paths.is_empty()); + } +} + +#[test] +fn recipient_never_adopts_reply_identity_bundle_epoch_or_item_context() { + let mut f = Fixture::new(); + let replies = f.replies.clone(); + for (index, path, value) in [ + (0, "/user/id", json!(OWNER)), + (1, "/keyEpoch", json!(2)), + (2, "/bundleVersion", json!(2)), + (3, "/keyEpoch", json!(2)), + (3, "/envelope/context/keyEpoch", json!(2)), + (4, "/keyEpoch", json!(2)), + (5, "/context/itemVersion", json!(4)), + (5, "/ciphertext", json!("tampered")), + ] { + f.replies = replies.clone(); + *f.replies[index].pointer_mut(path).unwrap() = value; + let (out, paths) = f.run(&["item", "get", LEGACY_ITEM, "--vault", BOX], None); + assert!(!out.status.success()); + assert_eq!(paths.len(), index + 1); + } +} diff --git a/documents/runbooks/op-cli.md b/documents/runbooks/op-cli.md index 31399170..2e1c0bc3 100644 --- a/documents/runbooks/op-cli.md +++ b/documents/runbooks/op-cli.md @@ -7,7 +7,7 @@ 開発者・運用者が **op 互換 CLI** をビルドし、パスワードボックスと項目を操作できるようにする。 暗号化したボックスは明示的な `--encrypted` モードを使う。 -[CLI の暗号化モード](../spec/encrypted-cli.md) に入力境界、所有者限定、再送と未対応範囲を記載する。 +[CLI の暗号化モード](../spec/encrypted-cli.md) に入力境界、所有者の操作、共有受信者の読取、再送と未対応範囲を記載する。 ## 前提 @@ -75,7 +75,7 @@ op read "op://My Password Box/Example/password" ## 検証 -### 暗号化モード(所有者のみ) +### 暗号化モード(所有者) 秘密管理ツールから `sessionToken` と `encryptionPassword` を含むJSONをパイプで渡す。 秘密値をコマンド引数やシェル履歴へ書かない。 @@ -92,7 +92,27 @@ op --encrypted --secret-stdin item retry --vault "<box-id>" 作成のJSONに `item`、編集のJSONに `selectedFields`、再送のJSONに保持した `preparedWrite` を含める。 一覧は秘密値を表示しない。取得は利用者が明示した秘密値を標準出力へ出すため、出力先を管理する。 現在の利用者・所有者・鍵世代・バンドル版は毎回取得する。401・403・409や通信失敗を自動再送しない。 -共有受信者と認証情報の永続保管は未対応であり、M4全体の完成を示さない。 +認証情報の永続保管は未対応であり、M4全体の完成を示さない。 + +### 暗号化モード(共有受信者の読取) + +`--recipient --secret-stdin` を追加し、確認済みの送り手・自身の公開identity、 +共有context、鍵世代、自身のidentityバンドル版を `sharedReceive` に明示する。 +公開鍵は別手段で照合し、サーバーの返信からpinや期待世代を作らない。 +入力の完全な形は [受信者の有限読取仕様](../spec/encrypted-cli.md#共有受信者の有限読取) を参照する。 +項目の取得には独立した `itemContext`、添付にはさらに `attachmentContext` が必要。 + +```powershell +# 秘密管理ツールからJSONをパイプする。秘密値はargvに書かない。 +op --encrypted --recipient --secret-stdin item list --vault "<box-id>" +op --encrypted --recipient --secret-stdin item get "<item-id>" --vault "<box-id>" --fields password +op --encrypted --recipient --secret-stdin read "op://<box-id>/<item-id>/password" +op --encrypted --recipient --secret-stdin attachment get "<attachment-id>" --item "<item-id>" --vault "<box-id>" --out "<new-file-path>" +``` + +添付の保存先は必ず利用者が指定する。既存ファイルは上書きしない。 +成功時も標準出力には何も出さず、`--print-name` を付けたときだけ認証した元の名前をJSON文字列で返す。 +受信者による書込み・鍵更新・移行・復旧は未対応。401・403・409を自動再送しない。 ```powershell cargo nextest run --locked -p op-cli -p passshare-connect diff --git a/documents/spec/encrypted-cli.md b/documents/spec/encrypted-cli.md index afc681e5..b6b5c6c6 100644 --- a/documents/spec/encrypted-cli.md +++ b/documents/spec/encrypted-cli.md @@ -7,6 +7,7 @@ originalToDo1163 / M4 の CLI 接続部分です。製品全体・全クライ `op --encrypted` を明示した場合だけ、現在有効なセッションを使います。 `item list/get/create/edit/delete`、`read`、限定された `vault list`、`item retry` が対象です。 +共有受信者は `--recipient --secret-stdin` を併用し、後述の有限な読取だけを行います。 コマンドの `vault` / `--vault` は op 互換の綴りであり、指す対象はパスワードボックスです。 暗号化モードの指定がない場合は、従来の Connect API の操作を維持します。 @@ -31,6 +32,7 @@ IDと鍵世代だけを表示します。セッションAPIによるアカウン | `item` | 作成時の全項目JSON。`title`、対応する大文字の`category`、`fields`配列が必要 | | `selectedFields` | 編集するフィールド名・IDと文字列値のオブジェクト。`title`も選択可 | | `preparedWrite` | 明示的な再送時に渡す、準備済みの不透明な要求 | +| `sharedReceive` | `--recipient` の独立した信頼入力。所有者の操作では拒否 | 標準入力を選んだ場合、環境変数の認証情報は補充しません。端末からの直接入力は、入力値が表示されるため拒否します。 入力JSONは2MiBまで、認証情報の文字列は各16KiBまでです。Coreの平文上限1MiBがBase64の暗号文に @@ -135,6 +137,119 @@ CLI manifestはworkspaceの `uuid` を直接参照し、検査用にはworkspace 今回の追加修正は `encrypted.rs` と関連検査・この仕様書だけです。 manifest・rootのCargo.lock・SDK/Core・他クライアント・出力用素材は変更していません。 -現在は所有者だけが対象です。共有受信者の鍵・指紋の独立した確認、共有鍵の受け取り、 -鍵更新の操作、認証情報の安全な永続保管、オフライン操作、ログイン自体、移行・復旧・添付の -CLI操作、全ボックスの発見は未提供です。他クライアントの実装状況をこの文書から推定しません。 +所有者の既存操作と、次の受信者の読取だけが対象です。鍵更新の操作、受信者による書込み、 +認証情報の安全な永続保管、オフライン操作、ログイン自体、移行・復旧、添付の変更操作、 +全ボックスの発見は未提供です。他クライアントの実装状況をこの文書から推定しません。 + +## 共有受信者の有限読取 + +`--encrypted --recipient --secret-stdin` は `item list/get`、`read`、`attachment get` だけに対応します。 +所有者の操作を自動的に受信者へ切り替えません。`vault list`、create/edit/delete/retry、 +`--prepare-only` は受信者モードで通信前に拒否します。受信者の秘密入力はパイプだけです。 +環境変数から信頼値や認証情報を補充せず、Connectへの自動切替も行いません。 + +最上位の入力キーは `sessionToken`、`encryptionPassword`、`sharedReceive` だけです。 +`encryptionPassword` は自身のidentityバンドルを開く暗号化パスワードです。 +次は形の説明であり、秘密値をシェルのリテラルへ記載する例ではありません。 + +```json +{ + "sessionToken": "<current-session>", + "encryptionPassword": "<identity-encryption-password>", + "sharedReceive": { + "sender": {"accountId":"<sender-uuid>","identityVersion":1,"publicKey":"<verified-base64>","keyId":"<verified-fingerprint>"}, + "recipient": {"accountId":"<self-uuid>","identityVersion":1,"publicKey":"<held-base64>","keyId":"<held-fingerprint>"}, + "context": { + "passwordBoxId":"<box-id>","senderAccountId":"<sender-uuid>","recipientAccountId":"<self-uuid>","keyEpoch":1, + "senderIdentityVersion":1,"recipientIdentityVersion":1,"senderKeyId":"<verified-fingerprint>","recipientKeyId":"<held-fingerprint>" + }, + "identityBundleVersion":1, + "itemContext":{"passwordBoxId":"<box-id>","itemId":"<item-id>","itemVersion":3,"keyEpoch":1}, + "attachmentContext":{"passwordBoxId":"<box-id>","itemId":"<item-id>","attachmentId":"<attachment-id>","attachmentVersion":1,"keyEpoch":1} + } +} +``` + +一覧では `itemContext` と `attachmentContext` を省略します。項目のget/readでは `itemContext` だけを付けます。 +添付getでは両方が必須です。context・公開identity・世代・版は独立して保持し、送り手と自身の公開identityを +別手段で照合した後に供給します。CLIは信頼の自動承認、サーバーからのpin採用・更新を提供しません。 +一覧の公開項目版を表示することと、暗号文自身から期待する版を採ることは区別します。 + +これらの制御JSONは未知・欠落・同名キー(エスケープの別表記を含む)・null・配列によるオブジェクト代用を拒否します。 +整数は1〜9,007,199,254,740,991、UUIDは小文字の正規表記かつnil以外です。 +公開identityはCoreのcanonical Base64と指紋・座標の検査も通します。 +版・世代・ルート・sender/recipientの全対応関係を、最初のHTTP前に検査します。 +全項目DTO内の未知属性やnullは従来どおり保持します。制御形式の拒否と、損失のない本文の保持を混同しません。 + +1. SDKの接続検査後、正式sessionの `/api/auth/me` を自身のidentityと照合します。 +2. ボックスの現在世代を独立した期待世代と照合します。 +3. `get_account_identity` で自身のバンドルを取得し、保持した公開identity・指紋・バンドル版と完全一致を要求します。 +4. 形式1固定KDFとNFCで鍵を導出し、Core `AccountIdentity::unwrap` で自身の秘密identityを復元します。 +5. SDK `get_self_grant(SharedKeyExpectation)` とCoreのHPKE Auth `unwrap_for_recipient` で共有鍵を開きます。 + +受信者を所有者用sharing-snapshotや所有者バンドルへ通しません。返信から期待世代やpinを更新しません。 +一覧は正式 `list` のページをID順に検査し、全ページ成功後にID・版だけを表示します。 +get/readは `get_attachment_state` → 同状態版の `get_attachment_snapshot` を各1回使い、 +独立した `ItemContext` に対してCoreで全項目JSONを認証します。明示した全項目または選択欄だけを標準出力へ返します。 + +添付getは同じ状態版の全項目をCoreで認証し、順序を保った `attachments` 配列を検査します。 +各参照の形は `{attachmentId,attachmentVersion,keyEpoch}` だけです。全参照のID重複・無効ID・ +世代不一致・不正整数を拒否し、指定した添付の版を独立した `AttachmentContext` と照合します。 +一致後にSDK `get_attachment` を1回だけ呼び、Coreで名前と全bytesを認証します。 +公開状態はCASの入力だけであり、暗号認証や鍵の信頼の根拠ではありません。 + +`--out <path>` は利用者が明示した保存先です。認証した名前をパスへ解釈せず、親フォルダも作りません。 +認証の成功後に `create_new` で新規作成し、既存ファイル・symlinkの上書きを拒否します。 +Unixでは新規ファイルを0600で作り、全bytesを書き込み・同期します。空ファイルも0 bytesのまま保持します。 +書込み失敗は失敗終了し、新規ファイルに一部だけ残る可能性を固定診断で伝えます。 +保存に成功しても標準出力は空です。`--print-name` を明示した場合だけ元の名前をJSON文字列で返し、 +Unicode・NUL・空の名前も損失なく扱います。鍵・本文・認証情報は、この明示した出力以外に保存しません。 +所有する秘密入力・NFCパスワードはZeroizingまたは再帰消去guard、鍵と添付の名前/bytesはCoreの消去付き型で保持します。 + +設定不備、暗号認証失敗、401・403・409、未確認、除名、旧grant、世代・identityの不一致は失敗のままです。 +HTTPの自動再送はありません。受信者による鍵更新・再共有・移行・復旧・変更操作はこのCLIでは未対応です。 + +## 受信者の正式受入 + +CLI専用のloopback検査は実 `op` と実SDK/Coreで行います。正式Node/PGを模擬した成功とは数えません。 +`tools/encrypted-storage-acceptance/src/cli_recipient.rs` は、既存補助処理を使う独立した明示実行の検査です。 +既存main・SQL輸送・local_postgres・添付受入の処理を変更せず、共有受入モジュールへ検査の登録だけを加えています。 + +```text +PASSSHARE_CLI_PG_MANIFEST=<absolute-owned-fixture-manifest> +PASSSHARE_CLI_NATIVE=<absolute-formal-native-binary> +PASSSHARE_CLI_BINARY=<absolute-op-binary> +PASSSHARE_CLI_OUTPUT_DIR=<absolute-directory-created-by-safe-delete> +cargo test --locked --manifest-path tools/encrypted-storage-acceptance/Cargo.toml sharing_rotation::cli_recipient::formal_cli_recipient -- --ignored --exact --nocapture +``` + +正式Node・所有情報を照合したPGの専用DB・実opで、全JSON/field/配列順、名前、全bytes、空ファイル、 +明示保存先、上書き拒否を確認します。実要求を転送するtapで、状態→同revision項目→添付が各1回のSDK要求であることを照合します。 +未確認と旧grantの拒否は専用DB内の明示した合成条件です。除名は正式な所有者の鍵更新APIを使い、 +全項目・全添付を新鍵へ包み直した後、旧sessionと新ログインの受信者を拒否することを確認します。 +全CLI要求の前後で全保存行・xminの不変を照合し、所有したNodeと専用DBだけを回収します。 +添付出力とビルド生成物の回収は呼出担当がsafe-deleteの所有記録に従って行います。 +親のPG・画面用DBは停止・変更しません。全workspace・配布image・CI・別モデルレビューは親担当の工程です。 + +2026-10-11の最終ソースで、関連nextestは82件成功・省略0(CLI18件、SDK64件)でした。 +CLI/SDKと受入ツールのall-targets Clippy `-D warnings`、変更Rustファイルの整形検査も成功しました。 +正式Node・PG・実opの明示受入は1件成功・省略0、24回のCLI実行で実SDKのGETを77回観測しました。 +この77回にはCLI内のauth/meを含めません。正常添付2件の各要求は、状態→同revision項目→添付が各1回でした。 +512 bytesの任意値と0 bytes、NUL/Unicode/空の名前、全JSON/field/配列順、上書き拒否を照合しました。 +未確認・旧grant・実APIによる除名後の旧/新sessionでは、項目と添付の両方を拒否し、ダウンロード先を作りませんでした。 +成功・拒否の全CLI実行で保存行とxminが不変でした。 + +受入コードの初回失敗は、Cookieログイン応答からBearer用のrefreshTokenを読もうとした取り違えでした。 +正式なmobileログイン応答を使う検査へ修正しました。次の失敗は、現在世代のgrantがない場合の正式404を +受入側が403と期待した誤りでした。正式契約に合わせ、世代2に対して旧世代1のgrantが残る明示した合成条件も検査しました。 +どちらもCLI/APIの権限を緩めていません。失敗時と最終成功時の専用DBは全て回収しました。 +親担当が調べている過去のpsql終了2は、この成功を根拠に修復済みとは扱いません。 + +片付け後に、今回作った4つの専用DBが全て不在であることをPGのcatalogで確認しました。 +親PGはPID68512・port50196・元の起動日時のままです。C1と他担当のDB・作業フォルダには触れていません。 +nextest・正式受入の成功ログと初回2つの失敗ログは、この子ジョブの記録フォルダへ保存しました。 +一時生成物2,203,338,425 bytesはsafe-deleteの容量上限により「削除希望」へ退避済みです。 +検査用pnpm依存6,541,350 bytesも同じDドライブの「削除希望」へ保持しました。 +後者の計画拒否は279件のjunctionによるもので、全て今回の元node_modules内を指すことを確認しました。 +リンク先を変更・削除せずフォルダ全体を移動し、理由を残して所有記録の元パス不在を記録しました。 +どちらも削除成功とは報告しません。追加のToDo・Discord投稿・push・PR・merge・全workspace検査は行っていません。 diff --git a/tools/encrypted-storage-acceptance/src/cli_recipient.rs b/tools/encrypted-storage-acceptance/src/cli_recipient.rs new file mode 100644 index 00000000..b6072f4b --- /dev/null +++ b/tools/encrypted-storage-acceptance/src/cli_recipient.rs @@ -0,0 +1,557 @@ +//! Explicit ignored acceptance: formal Node + verified owned PG + real op. +//! Only the caller-supplied disposable output directory receives plaintext. +use super::*; +use passshare_core::cloud_crypto::{AttachmentContext, AttachmentPlaintext}; +use std::process::Output; +use std::sync::{Arc, Mutex}; + +static CLI_INVOCATIONS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); +static SDK_GETS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +struct Tap { + port: u16, + routes: Arc<Mutex<Vec<String>>>, + stop: Arc<AtomicBool>, + worker: Option<thread::JoinHandle<()>>, +} +impl Tap { + fn start(upstream: u16) -> Self { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + listener.set_nonblocking(true).unwrap(); + let routes = Arc::new(Mutex::new(Vec::new())); + let stop = Arc::new(AtomicBool::new(false)); + let (record, ending) = (routes.clone(), stop.clone()); + let worker = thread::spawn(move || { + while !ending.load(Ordering::SeqCst) { + match listener.accept() { + Ok((mut client, _)) => { + client.set_nonblocking(false).unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + client + .set_write_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let mut header = Vec::new(); + while !header.ends_with(b"\r\n\r\n") { + assert!(header.len() < 65536, "CLI tap header bound"); + let mut byte = [0]; + client.read_exact(&mut byte).expect("CLI tap read"); + header.push(byte[0]); + } + let header = String::from_utf8(header).unwrap(); + let route = header.lines().next().unwrap().to_owned(); + assert!(route.starts_with("GET "), "CLI recipient must only read"); + assert!(!route.contains("sharing-snapshot") && !route.contains("/v1/")); + record.lock().unwrap().push(route); + let mut server = TcpStream::connect(("127.0.0.1", upstream)).unwrap(); + server + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + server + .set_write_timeout(Some(Duration::from_secs(10))) + .unwrap(); + for line in header.lines().filter(|l| !l.is_empty()) { + if !line + .split_once(':') + .is_some_and(|(k, _)| k.eq_ignore_ascii_case("connection")) + { + write!(server, "{line}\r\n").unwrap(); + } + } + server.write_all(b"Connection: close\r\n\r\n").unwrap(); + let mut response = Vec::new(); + server + .take(3 * 1024 * 1024 + 1) + .read_to_end(&mut response) + .unwrap(); + assert!(response.len() <= 3 * 1024 * 1024); + client.write_all(&response).unwrap(); + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(2)) + } + Err(_) => panic!("CLI tap accept"), + } + } + }); + Self { + port, + routes, + stop, + worker: Some(worker), + } + } + fn clear(&self) { + self.routes.lock().unwrap().clear(); + } + fn seen(&self) -> Vec<String> { + self.routes.lock().unwrap().clone() + } +} +impl Drop for Tap { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + if let Some(w) = self.worker.take() { + assert!(w.join().is_ok(), "CLI tap worker completion"); + } + } +} + +fn invoke(binary: &std::path::Path, tap: &Tap, input: &Value, args: &[&str]) -> Output { + tap.clear(); + let mut cmd = command(binary.to_str().unwrap()); + cmd.env_clear(); + for key in ["SystemRoot", "TEMP", "TMP"] { + if let Some(v) = std::env::var_os(key) { + cmd.env(key, v); + } + } + let mut child = cmd + .env("OP_CONNECT_HOST", "http://127.0.0.1:1") + .env("OP_CONNECT_TOKEN", "synthetic-connect-poison") + .args([ + "--encrypted", + "--recipient", + "--secret-stdin", + "--passshare-host", + &format!("http://127.0.0.1:{}", tap.port), + ]) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("real op launch"); + let mut pipe = child.stdin.take().unwrap(); + let write = serde_json::to_writer(&mut pipe, input); + drop(pipe); + assert!(write.is_ok(), "CLI pipe input"); + let out = child.stdout.take().unwrap(); + let err = child.stderr.take().unwrap(); + let out = thread::spawn(move || { + let mut b = Vec::new(); + out.take(2 * 1024 * 1024 + 1).read_to_end(&mut b).unwrap(); + b + }); + let err = thread::spawn(move || { + let mut b = Vec::new(); + err.take(65537).read_to_end(&mut b).unwrap(); + b + }); + let deadline = std::time::Instant::now() + Duration::from_secs(45); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("real op deadline"); + } + thread::sleep(Duration::from_millis(5)); + }; + let result = Output { + status, + stdout: out.join().unwrap(), + stderr: err.join().unwrap(), + }; + assert!(result.stdout.len() <= 2 * 1024 * 1024 && result.stderr.len() <= 65536); + for key in ["sessionToken", "encryptionPassword"] { + let value = input[key].as_str().unwrap(); + assert!(!String::from_utf8_lossy(&result.stdout).contains(value)); + assert!(!String::from_utf8_lossy(&result.stderr).contains(value)); + } + if !result.status.success() { + assert!(result.stdout.is_empty()); + assert!(!String::from_utf8_lossy(&result.stderr).contains("synthetic-secret")); + } + result +} +fn read_preserves( + db: &Database, + binary: &std::path::Path, + tap: &Tap, + input: &Value, + args: &[&str], + success: bool, +) -> Output { + let before = state_hash(db, true); + let result = invoke(binary, tap, input, args); + assert!( + result.status.success() == success, + "formal CLI outcome (diagnostics suppressed)" + ); + assert!( + state_hash(db, true) == before, + "CLI success/rejection preserves every full row and xmin" + ); + CLI_INVOCATIONS.fetch_add(1, Ordering::SeqCst); + SDK_GETS.fetch_add( + tap.seen() + .iter() + .filter(|p| !p.contains("/api/auth/me ")) + .count(), + Ordering::SeqCst, + ); + result +} +fn file_context(a: &Account, item: &str, id: &str, version: u64, epoch: u64) -> AttachmentContext { + serde_json::from_value(json!({"passwordBoxId":a.box_id,"itemId":item,"attachmentId":id,"attachmentVersion":version,"keyEpoch":epoch})).unwrap() +} + +#[test] +#[ignore = "requires explicit verified local PG manifest, native/op binaries and owned output directory"] +fn formal_cli_recipient() { + let supplied = + |name: &str| PathBuf::from(std::env::var_os(name).expect("explicit CLI fixture input")); + let manifest = supplied("PASSSHARE_CLI_PG_MANIFEST"); + let binary = supplied("PASSSHARE_CLI_BINARY").canonicalize().unwrap(); + let native = supplied("PASSSHARE_CLI_NATIVE").canonicalize().unwrap(); + let output = supplied("PASSSHARE_CLI_OUTPUT_DIR").canonicalize().unwrap(); + assert!(output.is_dir() && manifest.is_absolute() && binary.is_file() && native.is_file()); + let db = Database::from_local( + local_postgres::LocalPostgres::start_attachments(&manifest), + "source", + ); + println!( + "CLI fixture created: database={} port={} owner={}", + db.name, db.port, db.fixture_owner_id + ); + let network = Network { + name: String::new(), + }; + let host = Host::start(&db, &network, &Mode::Node(native)); + let p = host.port; + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .unwrap(); + let a = Account::signup(p); + let b = Account::signup(p); + a.register(p, &rt); + b.register(p, &rt); + let key = PasswordBoxKey::generate().unwrap(); + call( + p, + &a, + "POST", + &format!("{}/setup", root(&a)), + &json!({"bundle":owner_bundle(&key,&a.master,a.salt,&owner_context(&a.box_id,&a.id,1))}), + 200, + ); + accept(p, &b, &invite(p, &a, &b)); + let rev = || number(&snapshot(p, &a), "stateRevision"); + call( + p, + &a, + "PUT", + &format!("{}/grants/{}", root(&a), b.id), + &json!({"expectedStateRevision":rev(),"expectedVersion":0,"keyEpoch":1,"envelope":grant(&key,&a,&b,1)}), + 200, + ); + let item = ConnectId::generate().unwrap().to_string(); + let files = [ + AttachmentPlaintext::new( + "../original\0éЖ🦀.bin".into(), + (0..=255).chain((0..=255).rev()).collect(), + ) + .unwrap(), + AttachmentPlaintext::new(String::new(), Vec::new()).unwrap(), + ]; + let ids = [ + ConnectId::generate().unwrap().to_string(), + ConnectId::generate().unwrap().to_string(), + ]; + let mut dto = json!({"id":item,"vault":{"id":a.box_id,"name":"fixture"},"version":1,"category":"CUSTOM","title":"fixture", + "fields":[{"id":"second","value":"kept","future":[3,1,2]},{"id":"first","purpose":"PASSWORD","value":"synthetic-secret\0Ж"}], + "sections":[{"id":"z"},{"id":"a"}],"urls":[{"href":"https://second.example.test/"},{"href":"https://first.example.test/"}], + "tags":["z","a","z"],"favorite":true,"future":{"ordered":[3,1,2],"null":null,"nested":{"flag":false}},"attachments":[]}); + call( + p, + &a, + "PUT", + &format!("{}/items/{item}", root(&a)), + &json!({"expectedVersion":0,"envelope":key.encrypt_item(&dto,&context(&a,&item,1,1)).unwrap()}), + 200, + ); + for (i, file) in files.iter().enumerate() { + let c = file_context(&a, &item, &ids[i], 1, 1); + dto["version"] = json!(i + 2); + dto["attachments"] + .as_array_mut() + .unwrap() + .push(json!({"attachmentId":ids[i],"attachmentVersion":1,"keyEpoch":1})); + call( + p, + &a, + "PUT", + &format!("{}/items/{item}/attachments/{}", root(&a), ids[i]), + &json!({"expectedStateRevision":rev(),"expectedVersion":0,"item":{"expectedVersion":i+1,"envelope":key.encrypt_item(&dto,&context(&a,&item,i as u64+2,1)).unwrap()},"envelope":key.encrypt_attachment(file,&c).unwrap()}), + 200, + ); + } + let mut input = json!({"sessionToken":b.session,"encryptionPassword":b.encryption_password,"sharedReceive":{ + "sender":a.pin,"recipient":b.pin,"context":shared_context(&a,&b,1),"identityBundleVersion":1,"itemContext":context(&a,&item,3,1)}}); + let valid = input.clone(); + let tap = Tap::start(p); + let get = ["item", "get", &item, "--vault", &a.box_id]; + let result = read_preserves(&db, &binary, &tap, &input, &get, true); + assert!( + serde_json::from_slice::<Value>(&result.stdout).unwrap() == dto, + "all JSON/fields/unknown properties/array order" + ); + let result = read_preserves( + &db, + &binary, + &tap, + &input, + &[ + "item", "get", &item, "--vault", &a.box_id, "--fields", "password", + ], + true, + ); + assert!(String::from_utf8(result.stdout).unwrap() == "synthetic-secret\0Ж\n"); + let reference = format!("op://{}/{item}/password", a.box_id); + let result = read_preserves(&db, &binary, &tap, &input, &["read", &reference], true); + assert!(String::from_utf8(result.stdout).unwrap() == "synthetic-secret\0Ж\n"); + input["sharedReceive"] + .as_object_mut() + .unwrap() + .remove("itemContext"); + let result = read_preserves( + &db, + &binary, + &tap, + &input, + &["item", "list", "--vault", &a.box_id], + true, + ); + assert!(String::from_utf8(result.stdout).unwrap() == format!("{item}\t3\n")); + let revision = rev(); + for (i, file) in files.iter().enumerate() { + input = valid.clone(); + input["sharedReceive"]["attachmentContext"] = json!(file_context(&a, &item, &ids[i], 1, 1)); + let path = output.join(format!("cli-file-{}", ConnectId::generate().unwrap())); + let dest = path.to_str().unwrap(); + let args = [ + "attachment", + "get", + &ids[i], + "--item", + &item, + "--vault", + &a.box_id, + "--out", + dest, + "--print-name", + ]; + let result = read_preserves(&db, &binary, &tap, &input, &args, true); + assert!( + std::fs::read(&path).unwrap() == file.content(), + "all file bytes including empty file" + ); + assert!( + serde_json::from_slice::<String>(&result.stdout).unwrap() == file.name(), + "original Unicode/NUL/empty names preserved" + ); + assert!( + tap.seen() + == vec![ + "GET /api/auth/me HTTP/1.1".to_owned(), + format!("GET {} HTTP/1.1", root(&a)), + "GET /api/encrypted/account-identity HTTP/1.1".to_owned(), + format!("GET {}/grants/me HTTP/1.1", root(&a)), + format!("GET {}/attachment-state HTTP/1.1", root(&a)), + format!( + "GET {}/items/{item}/attachment-snapshot?revision={revision} HTTP/1.1", + root(&a) + ), + format!( + "GET {}/items/{item}/attachments/{}?revision={revision} HTTP/1.1", + root(&a), + ids[i] + ) + ], + "actual SDK paths, one request each, one revision" + ); + read_preserves(&db, &binary, &tap, &input, &args, false); + assert!( + std::fs::read(&path).unwrap() == file.content(), + "existing destination unchanged" + ); + } + let refused_path = output.join(format!("cli-refused-{}", ConnectId::generate().unwrap())); + let refused_args = [ + "attachment", + "get", + &ids[0], + "--item", + &item, + "--vault", + &a.box_id, + "--out", + refused_path.to_str().unwrap(), + ]; + let refuse_attachment = |input: &Value, epoch: u64| { + let mut input = input.clone(); + input["sharedReceive"]["attachmentContext"] = + json!(file_context(&a, &item, &ids[0], epoch, epoch)); + read_preserves(&db, &binary, &tap, &input, &refused_args, false); + assert!( + !refused_path.exists(), + "rejected download never creates a file" + ); + }; + input = valid.clone(); + input["sharedReceive"]["attachmentContext"] = json!(file_context(&a, &item, &ids[0], 2, 1)); + read_preserves(&db, &binary, &tap, &input, &refused_args, false); + assert_eq!( + tap.seen().len(), + 6, + "mismatched authenticated reference never requests attachment" + ); + assert!(!refused_path.exists()); + input = valid.clone(); + // Wrong but self-validating sender pin, independently supplied, is not + // replaced from the grant reply. No plaintext or file is created. + let wrong = AccountIdentity::generate(a.identity.context().clone()) + .unwrap() + .public_identity(); + input["sharedReceive"]["sender"] = json!(wrong); + input["sharedReceive"]["context"]["senderKeyId"] = json!(wrong.key_id); + read_preserves(&db, &binary, &tap, &input, &get, false); + assert_eq!(tap.seen().len(), 4); + input = valid.clone(); + let wrong_self = AccountIdentity::generate(b.identity.context().clone()) + .unwrap() + .public_identity(); + input["sharedReceive"]["recipient"] = json!(wrong_self); + input["sharedReceive"]["context"]["recipientKeyId"] = json!(wrong_self.key_id); + read_preserves(&db, &binary, &tap, &input, &get, false); + assert_eq!(tap.seen().len(), 3); + input = valid.clone(); + input["sharedReceive"]["itemContext"]["itemVersion"] = json!(2); + read_preserves(&db, &binary, &tap, &input, &get, false); + assert_eq!(tap.seen().len(), 6); + input = valid.clone(); + input["sessionToken"] = json!(b.connect); + let failed = read_preserves(&db, &binary, &tap, &input, &get, false); + assert!(String::from_utf8_lossy(&failed.stderr).contains("HTTP 401")); + assert_eq!(tap.seen().len(), 1); + input = valid.clone(); + // Explicit synthetic confirmation and stale-grant conditions. Each CLI + // rejection must itself preserve all rows/xmin; no auth rule is relaxed. + db.sql(&format!( + "UPDATE vault_memberships SET confirmed_at=NULL WHERE vault_id='{}' AND user_id='{}'", + a.box_id, b.id + )); + let failed = read_preserves(&db, &binary, &tap, &input, &get, false); + assert!(String::from_utf8_lossy(&failed.stderr).contains("HTTP 403")); + refuse_attachment(&input, 1); + db.sql(&format!("UPDATE vault_memberships SET confirmed_at=clock_timestamp() WHERE vault_id='{}' AND user_id='{}'",a.box_id,b.id)); + // Current public epoch 2 with the original epoch-1 grant: an explicitly + // synthetic stale-grant condition, restored before the real rotation. + db.sql(&format!( + "UPDATE encrypted_box_state SET key_epoch=2 WHERE box_id='{}'", + a.box_id + )); + input["sharedReceive"]["context"]["keyEpoch"] = json!(2); + input["sharedReceive"]["itemContext"]["keyEpoch"] = json!(2); + let failed = read_preserves(&db, &binary, &tap, &input, &get, false); + assert!(String::from_utf8_lossy(&failed.stderr).contains("HTTP 404")); + assert_eq!(tap.seen().len(), 4); + refuse_attachment(&input, 2); + db.sql(&format!( + "UPDATE encrypted_box_state SET key_epoch=1 WHERE box_id='{}'", + a.box_id + )); + input = valid.clone(); + db.sql(&format!( + "UPDATE vault_memberships SET role='readonly' WHERE vault_id='{}' AND user_id='{}'", + a.box_id, b.id + )); + read_preserves(&db, &binary, &tap, &input, &get, true); + // Real owner rotation removes B and seals every surviving item/file. The + // recipient CLI does not perform this unsupported write operation. + let new = PasswordBoxKey::generate().unwrap(); + let op = operation_id(&db); + let rotation = format!("{}/rotations/{op}", root(&a)); + call( + p, + &a, + "PUT", + &rotation, + &json!({"expectedStateRevision":rev(),"expectedKeyEpoch":1,"expectedOwnerBundleVersion":1,"removeAccountId":b.id, + "bundle":owner_bundle(&new,&a.master,a.salt,&owner_context(&a.box_id,&a.id,2))}), + 200, + ); + let mut next = dto.clone(); + next["version"] = json!(4); + for r in next["attachments"].as_array_mut().unwrap() { + r["attachmentVersion"] = json!(2); + r["keyEpoch"] = json!(2); + } + call( + p, + &a, + "PUT", + &format!("{rotation}/items/{item}"), + &json!({"expectedVersion":3,"envelope":new.encrypt_item(&next,&context(&a,&item,4,2)).unwrap()}), + 200, + ); + for (i, file) in files.iter().enumerate() { + call( + p, + &a, + "PUT", + &format!("{rotation}/items/{item}/attachments/{}", ids[i]), + &json!({"expectedVersion":1,"envelope":new.encrypt_attachment(file,&file_context(&a,&item,&ids[i],2,2)).unwrap()}), + 200, + ); + } + let committed = call( + p, + &a, + "POST", + &format!("{rotation}/commit"), + &json!({"currentPassword":a.login_password}), + 200, + ); + assert!(committed["status"] == "committed" && committed["keyEpoch"] == 2); + read_preserves(&db, &binary, &tap, &input, &get, false); + refuse_attachment(&input, 1); + let (status, login) = http( + p, + "POST", + "/api/auth/login", + "x-passshare-client: mobile\r\n", + &json!({"email":b.email,"password":b.login_password}), + ); + assert_eq!( + status, 200, + "removed recipient can log in to their own account" + ); + input["sessionToken"] = login["refreshToken"].clone(); + read_preserves(&db, &binary, &tap, &input, &get, false); + refuse_attachment(&input, 1); + input["sharedReceive"]["context"]["keyEpoch"] = json!(2); + input["sharedReceive"]["itemContext"] = json!(context(&a, &item, 4, 2)); + read_preserves(&db, &binary, &tap, &input, &get, false); + refuse_attachment(&input, 2); + drop(tap); + drop(host); + drop(db); + assert!( + !CLEANUP_FAILED.load(Ordering::SeqCst), + "owned Node/DB cleanup completed" + ); + println!("formal CLI recipient passed: complete DTO/list/get/read; 2 names and all bytes including empty; exact single SDK requests; no overwrite; wrong pin/old item/Connect/unconfirmed/stale grant and actual removal old/new session refusals; all CLI reads preserve full rows/xmin; owned Node and DB removed"); + println!( + "CLI_INVOCATIONS={} SDK_GETS={}", + CLI_INVOCATIONS.load(Ordering::SeqCst), + SDK_GETS.load(Ordering::SeqCst) + ); +} diff --git a/tools/encrypted-storage-acceptance/src/sharing_rotation.rs b/tools/encrypted-storage-acceptance/src/sharing_rotation.rs index 71a05890..7b2af8ac 100644 --- a/tools/encrypted-storage-acceptance/src/sharing_rotation.rs +++ b/tools/encrypted-storage-acceptance/src/sharing_rotation.rs @@ -22,6 +22,10 @@ mod legacy_expiry; #[path = "sharing_sdk.rs"] mod sharing_sdk; +#[cfg(test)] +#[path = "cli_recipient.rs"] +mod cli_recipient; + fn request_slot() { // Honor the REAL formal router quota; never increase its limit or rewrite // rate-limit DB rows. This pacing is unrelated to lock-race evidence. From 917ec49737561a9bb483a73512858a7ec7d4177f Mon Sep 17 00:00:00 2001 From: mashfromband <matsumoto.yukuhashi@gmail.com> Date: Sun, 11 Oct 2026 19:03:37 +0900 Subject: [PATCH 02/10] test(acceptance): run recipient CLI against Node and image fixtures --- .github/workflows/encrypted-storage.yml | 19 +++++++ documents/spec/encrypted-cli.md | 10 ++++ .../src/cli_recipient.rs | 55 ++++++++++++++----- 3 files changed, 71 insertions(+), 13 deletions(-) diff --git a/.github/workflows/encrypted-storage.yml b/.github/workflows/encrypted-storage.yml index 0777140e..4caf414d 100644 --- a/.github/workflows/encrypted-storage.yml +++ b/.github/workflows/encrypted-storage.yml @@ -39,6 +39,7 @@ jobs: cargo clippy --manifest-path tools/encrypted-storage-acceptance/Cargo.toml --locked --all-targets -- -D warnings cargo build --manifest-path tools/encrypted-storage-acceptance/Cargo.toml --locked cargo build --manifest-path backend/native/Cargo.toml --locked --bin passshare-mutations + cargo build --locked -p op-cli - name: Load the pinned PostgreSQL fixture run: docker pull mirror.gcr.io/library/postgres@sha256:b0f9560a2de083e2cc7382e75f808c7381a32852a7ec49117deedb300e552b24 - name: Verify repeated restore ownership and rejection invariants @@ -52,6 +53,15 @@ jobs: run: tools/encrypted-storage-acceptance/target/debug/passshare-encrypted-storage-acceptance --go-session "$RUNNER_TEMP/passshare-go-session.test" backend/native/target/debug/passshare-mutations - name: Verify account identity with formal Node and Rust Core run: tools/encrypted-storage-acceptance/target/debug/passshare-encrypted-storage-acceptance --account-identity backend/native/target/debug/passshare-mutations + - name: Verify real recipient CLI with formal Node and owned PostgreSQL + env: + PASSSHARE_CLI_DOCKER_MODE: node + PASSSHARE_CLI_NATIVE: ${{ github.workspace }}/backend/native/target/debug/passshare-mutations + PASSSHARE_CLI_BINARY: ${{ github.workspace }}/target/debug/op + PASSSHARE_CLI_OUTPUT_DIR: ${{ runner.temp }}/passshare1163-cli-node + run: | + mkdir "$PASSSHARE_CLI_OUTPUT_DIR" + cargo test --manifest-path tools/encrypted-storage-acceptance/Cargo.toml --locked sharing_rotation::cli_recipient::formal_cli_recipient -- --ignored --exact --test-threads=1 - name: Verify atomic encrypted attachment storage and rotation with formal Node run: tools/encrypted-storage-acceptance/target/debug/passshare-encrypted-storage-acceptance --encrypted-attachments backend/native/target/debug/passshare-mutations - name: Verify complete owner migration with the formal Node API @@ -70,6 +80,15 @@ jobs: run: tools/encrypted-storage-acceptance/target/debug/passshare-encrypted-storage-acceptance --go-session "$RUNNER_TEMP/passshare-go-session.test" --image passshare-todo1163-encrypted-api - name: Verify account identity with the distribution image and Rust Core run: tools/encrypted-storage-acceptance/target/debug/passshare-encrypted-storage-acceptance --account-identity --image passshare-todo1163-encrypted-api + - name: Verify real recipient CLI with the distribution image and owned PostgreSQL + env: + PASSSHARE_CLI_DOCKER_MODE: image + PASSSHARE_CLI_IMAGE: passshare-todo1163-encrypted-api + PASSSHARE_CLI_BINARY: ${{ github.workspace }}/target/debug/op + PASSSHARE_CLI_OUTPUT_DIR: ${{ runner.temp }}/passshare1163-cli-image + run: | + mkdir "$PASSSHARE_CLI_OUTPUT_DIR" + cargo test --manifest-path tools/encrypted-storage-acceptance/Cargo.toml --locked sharing_rotation::cli_recipient::formal_cli_recipient -- --ignored --exact --test-threads=1 - name: Verify atomic encrypted attachment storage and rotation with the distribution image run: tools/encrypted-storage-acceptance/target/debug/passshare-encrypted-storage-acceptance --encrypted-attachments --image passshare-todo1163-encrypted-api - name: Verify complete owner migration with the distribution image diff --git a/documents/spec/encrypted-cli.md b/documents/spec/encrypted-cli.md index b6b5c6c6..ea9f9918 100644 --- a/documents/spec/encrypted-cli.md +++ b/documents/spec/encrypted-cli.md @@ -253,3 +253,13 @@ nextest・正式受入の成功ログと初回2つの失敗ログは、この子 後者の計画拒否は279件のjunctionによるもので、全て今回の元node_modules内を指すことを確認しました。 リンク先を変更・削除せずフォルダ全体を移動し、理由を残して所有記録の元パス不在を記録しました。 どちらも削除成功とは報告しません。追加のToDo・Discord投稿・push・PR・merge・全workspace検査は行っていません。 + +### Linuxと配布イメージの正式CLI検査 + +親の統合候補では、同じ `formal_cli_recipient` を正式Nodeと固定Dockerfileの配布イメージでも実行する。 +`PASSSHARE_CLI_DOCKER_MODE` を `node` または `image` に明示し、既存の所有ラベル・固定PostgreSQLイメージ・ +所有者マーカーを持つ専用DBとネットワークを作る。Windowsの所有済みPGは、従来どおり絶対パスの +`PASSSHARE_CLI_PG_MANIFEST` だけで選ぶ。両方の指定、未知の指定、指定なしでは開始しない。 +CLI本体・Node用のnative本体・新しい出力先は呼出側が明示する。イメージ用の検査はホストnativeを +コンテナへ持ち込まない。同じ全JSON・添付全バイト・失効・上書き拒否・単一要求の判定を維持し、 +Nodeとイメージの検査がそれぞれ成功するまで実行済みと数えない。 diff --git a/tools/encrypted-storage-acceptance/src/cli_recipient.rs b/tools/encrypted-storage-acceptance/src/cli_recipient.rs index b6072f4b..c2cfed07 100644 --- a/tools/encrypted-storage-acceptance/src/cli_recipient.rs +++ b/tools/encrypted-storage-acceptance/src/cli_recipient.rs @@ -1,4 +1,4 @@ -//! Explicit ignored acceptance: formal Node + verified owned PG + real op. +//! Explicit ignored acceptance: Node/image + owned PG + real op. //! Only the caller-supplied disposable output directory receives plaintext. use super::*; use passshare_core::cloud_crypto::{AttachmentContext, AttachmentPlaintext}; @@ -202,27 +202,56 @@ fn file_context(a: &Account, item: &str, id: &str, version: u64, epoch: u64) -> } #[test] -#[ignore = "requires explicit verified local PG manifest, native/op binaries and owned output directory"] +#[ignore = "requires explicit owned local/Docker PG, backend/op binaries and owned output directory"] fn formal_cli_recipient() { let supplied = |name: &str| PathBuf::from(std::env::var_os(name).expect("explicit CLI fixture input")); - let manifest = supplied("PASSSHARE_CLI_PG_MANIFEST"); let binary = supplied("PASSSHARE_CLI_BINARY").canonicalize().unwrap(); - let native = supplied("PASSSHARE_CLI_NATIVE").canonicalize().unwrap(); let output = supplied("PASSSHARE_CLI_OUTPUT_DIR").canonicalize().unwrap(); - assert!(output.is_dir() && manifest.is_absolute() && binary.is_file() && native.is_file()); - let db = Database::from_local( - local_postgres::LocalPostgres::start_attachments(&manifest), - "source", - ); + assert!(output.is_dir() && binary.is_file()); + let manifest = std::env::var_os("PASSSHARE_CLI_PG_MANIFEST"); + let source = std::env::var_os("PASSSHARE_CLI_DOCKER_MODE"); + let source = source + .as_ref() + .map(|value| value.to_str().expect("CLI fixture selector must be UTF-8")); + let (network, db, mode) = match (manifest, source) { + (Some(manifest), None) => { + let manifest = PathBuf::from(manifest); + assert!(manifest.is_absolute()); + let native = supplied("PASSSHARE_CLI_NATIVE").canonicalize().unwrap(); + assert!(native.is_file()); + ( + Network { + name: String::new(), + }, + Database::from_local( + local_postgres::LocalPostgres::start_attachments(&manifest), + "source", + ), + Mode::Node(native), + ) + } + (None, Some("node" | "image")) => { + let mode = if source == Some("node") { + let native = supplied("PASSSHARE_CLI_NATIVE").canonicalize().unwrap(); + assert!(native.is_file()); + Mode::Node(native) + } else { + let tag = + std::env::var_os("PASSSHARE_CLI_IMAGE").expect("explicit owned CLI image"); + Mode::parse(&["--image".into(), tag]).expect("valid owned CLI image tag") + }; + let network = Network::start(); + let db = Database::start(&network); + (network, db, mode) + } + _ => panic!("choose exactly one explicit local or Docker CLI fixture"), + }; println!( "CLI fixture created: database={} port={} owner={}", db.name, db.port, db.fixture_owner_id ); - let network = Network { - name: String::new(), - }; - let host = Host::start(&db, &network, &Mode::Node(native)); + let host = Host::start(&db, &network, &mode); let p = host.port; let rt = tokio::runtime::Builder::new_multi_thread() .worker_threads(1) From 5e08d5f0463f48cce585683d9c814e4779e1a4a3 Mon Sep 17 00:00:00 2001 From: mashfromband <matsumoto.yukuhashi@gmail.com> Date: Sun, 11 Oct 2026 19:05:12 +0900 Subject: [PATCH 03/10] test(acceptance): retain real CLI request counts in CI logs --- .github/workflows/encrypted-storage.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/encrypted-storage.yml b/.github/workflows/encrypted-storage.yml index 4caf414d..d9b86491 100644 --- a/.github/workflows/encrypted-storage.yml +++ b/.github/workflows/encrypted-storage.yml @@ -61,7 +61,7 @@ jobs: PASSSHARE_CLI_OUTPUT_DIR: ${{ runner.temp }}/passshare1163-cli-node run: | mkdir "$PASSSHARE_CLI_OUTPUT_DIR" - cargo test --manifest-path tools/encrypted-storage-acceptance/Cargo.toml --locked sharing_rotation::cli_recipient::formal_cli_recipient -- --ignored --exact --test-threads=1 + cargo test --manifest-path tools/encrypted-storage-acceptance/Cargo.toml --locked sharing_rotation::cli_recipient::formal_cli_recipient -- --ignored --exact --test-threads=1 --nocapture - name: Verify atomic encrypted attachment storage and rotation with formal Node run: tools/encrypted-storage-acceptance/target/debug/passshare-encrypted-storage-acceptance --encrypted-attachments backend/native/target/debug/passshare-mutations - name: Verify complete owner migration with the formal Node API @@ -88,7 +88,7 @@ jobs: PASSSHARE_CLI_OUTPUT_DIR: ${{ runner.temp }}/passshare1163-cli-image run: | mkdir "$PASSSHARE_CLI_OUTPUT_DIR" - cargo test --manifest-path tools/encrypted-storage-acceptance/Cargo.toml --locked sharing_rotation::cli_recipient::formal_cli_recipient -- --ignored --exact --test-threads=1 + cargo test --manifest-path tools/encrypted-storage-acceptance/Cargo.toml --locked sharing_rotation::cli_recipient::formal_cli_recipient -- --ignored --exact --test-threads=1 --nocapture - name: Verify atomic encrypted attachment storage and rotation with the distribution image run: tools/encrypted-storage-acceptance/target/debug/passshare-encrypted-storage-acceptance --encrypted-attachments --image passshare-todo1163-encrypted-api - name: Verify complete owner migration with the distribution image From 43533369573e91f80428feb40fc2c3cfdc5f2a6a Mon Sep 17 00:00:00 2001 From: mashfromband <matsumoto.yukuhashi@gmail.com> Date: Sun, 11 Oct 2026 19:13:54 +0900 Subject: [PATCH 04/10] test(acceptance): observe network cleanup before reporting CLI success --- tools/encrypted-storage-acceptance/src/cli_recipient.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/encrypted-storage-acceptance/src/cli_recipient.rs b/tools/encrypted-storage-acceptance/src/cli_recipient.rs index c2cfed07..ed0a7fbf 100644 --- a/tools/encrypted-storage-acceptance/src/cli_recipient.rs +++ b/tools/encrypted-storage-acceptance/src/cli_recipient.rs @@ -573,9 +573,10 @@ fn formal_cli_recipient() { drop(tap); drop(host); drop(db); + drop(network); assert!( !CLEANUP_FAILED.load(Ordering::SeqCst), - "owned Node/DB cleanup completed" + "owned backend/DB/network cleanup completed" ); println!("formal CLI recipient passed: complete DTO/list/get/read; 2 names and all bytes including empty; exact single SDK requests; no overwrite; wrong pin/old item/Connect/unconfirmed/stale grant and actual removal old/new session refusals; all CLI reads preserve full rows/xmin; owned Node and DB removed"); println!( From 210aae1b5b01c98b9567248e4a28a55ad328d0f7 Mon Sep 17 00:00:00 2001 From: mashfromband <matsumoto.yukuhashi@gmail.com> Date: Sun, 11 Oct 2026 19:19:32 +0900 Subject: [PATCH 05/10] test(acceptance): name every reclaimed CLI fixture resource --- tools/encrypted-storage-acceptance/src/cli_recipient.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/encrypted-storage-acceptance/src/cli_recipient.rs b/tools/encrypted-storage-acceptance/src/cli_recipient.rs index ed0a7fbf..723d3aba 100644 --- a/tools/encrypted-storage-acceptance/src/cli_recipient.rs +++ b/tools/encrypted-storage-acceptance/src/cli_recipient.rs @@ -578,7 +578,7 @@ fn formal_cli_recipient() { !CLEANUP_FAILED.load(Ordering::SeqCst), "owned backend/DB/network cleanup completed" ); - println!("formal CLI recipient passed: complete DTO/list/get/read; 2 names and all bytes including empty; exact single SDK requests; no overwrite; wrong pin/old item/Connect/unconfirmed/stale grant and actual removal old/new session refusals; all CLI reads preserve full rows/xmin; owned Node and DB removed"); + println!("formal CLI recipient passed: complete DTO/list/get/read; 2 names and all bytes including empty; exact single SDK requests; no overwrite; wrong pin/old item/Connect/unconfirmed/stale grant and actual removal old/new session refusals; all CLI reads preserve full rows/xmin; owned backend, DB and network removed"); println!( "CLI_INVOCATIONS={} SDK_GETS={}", CLI_INVOCATIONS.load(Ordering::SeqCst), From 087f0c2426c8e95d71d30e9c6f14933557b81e1b Mon Sep 17 00:00:00 2001 From: mashfromband <matsumoto.yukuhashi@gmail.com> Date: Sun, 11 Oct 2026 18:42:05 +0900 Subject: [PATCH 06/10] feat(ui): connect encrypted items and attachments across three clients --- apps/desktop/src-tauri/tauri.conf.json | 3 +- apps/desktop/src/App.tsx | 10 +- .../src/components/CloudItemsPanel.tsx | 97 +++ apps/desktop/src/lib/cloud-sync.ts | 9 + apps/desktop/tsconfig.json | 2 +- apps/desktop/vite.config.ts | 4 +- apps/mobile/src-tauri/tauri.conf.json | 3 +- apps/mobile/src/components/ItemsPanel.tsx | 18 +- apps/mobile/src/hooks/useAuthSession.tsx | 18 + apps/mobile/tsconfig.json | 6 +- apps/mobile/vite.config.ts | 4 +- .../spec/cross-client-encrypted-items-ui.md | 86 +++ documents/spec/desktop-ui.md | 2 + documents/spec/extension-ui.md | 4 + documents/spec/mobile-ui.md | 4 + frontend/src/hooks/useItemLabels.ts | 53 +- frontend/src/lib/cloud-api.ts | 293 +-------- frontend/src/lib/cloud-worker.ts | 320 +--------- frontend/src/lib/connect-items.ts | 94 +-- frontend/src/lib/sharing-api.ts | 159 +---- frontend/src/pages/app/CloudSharingPanel.tsx | 263 +-------- frontend/src/pages/app/EncryptedItemsPage.tsx | 556 +----------------- frontend/src/pages/app/cloud-sharing.css | 11 +- frontend/src/pages/app/encrypted-items.css | 43 +- frontend/tests/attachments-ui.test.mjs | 20 +- frontend/tests/cross-client-cloud.test.mjs | 187 ++++++ .../examples/web_transport_acceptance.rs | 33 +- packages/extension/manifest.config.ts | 3 + .../src/background/service-worker.ts | 78 +-- packages/extension/src/content/autofill.ts | 30 +- packages/extension/src/lib/constants.ts | 2 + .../src/popup/components/ItemsPanel.tsx | 52 +- .../src/popup/hooks/useAuthSession.tsx | 48 +- packages/extension/tsconfig.json | 8 +- packages/extension/vite.config.ts | 4 +- shared/cloud/CloudBoxPanel.tsx | 151 +++++ shared/cloud/CloudSharingPanel.tsx | 255 ++++++++ shared/cloud/EncryptedItems.tsx | 551 +++++++++++++++++ shared/cloud/client-cloud.css | 16 + shared/cloud/cloud-api.ts | 294 +++++++++ shared/cloud/cloud-sharing.css | 10 + shared/cloud/cloud-worker.ts | 319 ++++++++++ shared/cloud/connect-items.ts | 93 +++ shared/cloud/encrypted-items.css | 42 ++ shared/cloud/session-transport.ts | 52 ++ shared/cloud/sharing-api.ts | 160 +++++ shared/cloud/useItemLabels.ts | 52 ++ shared/lib/1pux-import.ts | 7 + shared/vite/cloud-assets.ts | 43 ++ 49 files changed, 2719 insertions(+), 1853 deletions(-) create mode 100644 apps/desktop/src/components/CloudItemsPanel.tsx create mode 100644 documents/spec/cross-client-encrypted-items-ui.md create mode 100644 frontend/tests/cross-client-cloud.test.mjs create mode 100644 shared/cloud/CloudBoxPanel.tsx create mode 100644 shared/cloud/CloudSharingPanel.tsx create mode 100644 shared/cloud/EncryptedItems.tsx create mode 100644 shared/cloud/client-cloud.css create mode 100644 shared/cloud/cloud-api.ts create mode 100644 shared/cloud/cloud-sharing.css create mode 100644 shared/cloud/cloud-worker.ts create mode 100644 shared/cloud/connect-items.ts create mode 100644 shared/cloud/encrypted-items.css create mode 100644 shared/cloud/session-transport.ts create mode 100644 shared/cloud/sharing-api.ts create mode 100644 shared/cloud/useItemLabels.ts create mode 100644 shared/vite/cloud-assets.ts diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 7ba21de1..79874921 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -21,7 +21,8 @@ "security": { "csp": { "default-src": "'self'", - "script-src": "'self'", + "script-src": "'self' 'wasm-unsafe-eval'", + "worker-src": "'self'", "style-src": "'self'", "style-src-attr": "'unsafe-inline'", "img-src": "'self' asset: http://asset.localhost data: blob:", diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 6d54bf67..f4cb4521 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -7,8 +7,12 @@ import { listen } from "@tauri-apps/api/event"; import { AppHeader } from "./components/AppHeader"; import { MainPanel } from "./components/MainPanel"; import { SetupPanel } from "./components/SetupPanel"; +import { CloudItemsPanel } from "./components/CloudItemsPanel"; +import { useI18n } from "@shared/i18n/I18nProvider"; export function App() { + const { t } = useI18n(); + const [cloud, setCloud] = useState(false); const [view, setView] = useState<AppView>("setup"); const [boxPath, setBoxPath] = useState(""); const [masterPassword, setMasterPassword] = useState(""); @@ -40,6 +44,7 @@ export function App() { }, []); function handleLocked() { + window.dispatchEvent(new Event("passshare:cloud-lock")); generation.current += 1; setLockGeneration(generation.current); setMasterPassword(""); @@ -68,7 +73,10 @@ export function App() { <MatrixBackground className="matrix-rain--fixed" /> <main className="ps-app"> <AppHeader /> - {view === "setup" ? ( + <button type="button" className="ps-button ps-button--secondary" onClick={() => setCloud((value) => !value)}> + {cloud ? t("app.vault.back") : t("cloud.openSubmit")} + </button> + {cloud ? <CloudItemsPanel /> : view === "setup" ? ( <SetupPanel key={lockGeneration} boxPath={boxPath} diff --git a/apps/desktop/src/components/CloudItemsPanel.tsx b/apps/desktop/src/components/CloudItemsPanel.tsx new file mode 100644 index 00000000..03b44918 --- /dev/null +++ b/apps/desktop/src/components/CloudItemsPanel.tsx @@ -0,0 +1,97 @@ +import { useLayoutEffect, useRef, useState, type FormEvent } from "react"; +import { ConnectClient } from "@passshare/connect"; +import { useI18n } from "@shared/i18n/I18nProvider"; +import { CloudBoxPanel } from "@shared/cloud/CloudBoxPanel"; +import { createCloudSession, type CloudSession } from "@shared/cloud/session-transport"; +import { CloudApiError } from "@shared/cloud/cloud-api"; +import { useDesktopLabels } from "../hooks/useDesktopLabels"; + +// Desktop uses the existing token-session login response (mobile transport). +// Nothing is persisted; .pbox, its recovery and save lifecycle stay independent. +export function CloudItemsPanel() { + const { t } = useI18n(); + const labels = useDesktopLabels().sync; + const [session, setSession] = useState<CloudSession | null>(null); + const [boxes, setBoxes] = useState<Array<{ id: string; name: string }>>([]); + const [boxId, setBoxId] = useState(""); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(false); + const lifetime = useRef(0); + const alive = useRef(true); + const binding = useRef<CloudSession | null>(null); + const request = useRef<AbortController | null>(null); + const password = useRef<HTMLInputElement>(null); + const base = useRef<HTMLInputElement>(null); + const email = useRef<HTMLInputElement>(null); + function clear() { + ++lifetime.current; request.current?.abort(); request.current = null; + binding.current?.dispose(); binding.current = null; + if (password.current) password.current.value = ""; + setSession(null); setBoxes([]); setBoxId(""); setBusy(false); + } + useLayoutEffect(() => { + alive.current = true; + const background = () => { if (document.visibilityState === "hidden") clear(); }; + window.addEventListener("pagehide", clear); + window.addEventListener("passshare:cloud-lock", clear); + document.addEventListener("visibilitychange", background); + return () => { + alive.current = false; clear(); + window.removeEventListener("pagehide", clear); + window.removeEventListener("passshare:cloud-lock", clear); + document.removeEventListener("visibilitychange", background); + }; + }, []); + async function login(event: FormEvent) { + event.preventDefault(); + if (busy) return; + const generation = ++lifetime.current; + const controller = new AbortController(); request.current = controller; + const current = () => alive.current && generation === lifetime.current && !controller.signal.aborted && document.visibilityState !== "hidden"; + let body = JSON.stringify({ email: email.current?.value, password: password.current?.value }); + if (password.current) password.current.value = ""; + setBusy(true); setFailed(false); + let next: CloudSession | null = null; + try { + const url = new URL(base.current?.value ?? ""); + // Validate the same destination as the bounded session transport before login. + const probe = createCloudSession(url.href, "", ""); probe.dispose(); + const response = await fetch(new URL("/api/auth/login", url), { method: "POST", body, signal: controller.signal, + credentials: "omit", redirect: "error", cache: "no-store", headers: { "Content-Type": "application/json", "x-passshare-client": "mobile" } }); + body = ""; + if (response.status !== 200) throw new CloudApiError(response.status); + const auth = await response.json() as { user: { id: string }; refreshToken: string; connectAccessToken: string }; + if (!current()) return; + next = createCloudSession(url.href, auth.user.id, auth.refreshToken, current); + const client = new ConnectClient({ host: url.origin, token: auth.connectAccessToken }); + const list = await client.listVaults(); + if (!current()) { next.dispose(); return; } + binding.current = next; setSession(next); setBoxes(list); setBoxId(list[0]?.id ?? ""); + } catch { next?.dispose(); if (current()) setFailed(true); } + finally { body = ""; if (current()) { request.current = null; setBusy(false); } } + } + async function logout() { + const held = binding.current; + if (held) { + const response = held.transport("/api/auth/logout", { method: "POST" }); + // Hide and clear key/draft state immediately while formal logout runs. + setSession(null); setBoxes([]); setBoxId(""); + try { await response; } finally { clear(); } + } else clear(); + } + return <section className="ps-card client-cloud"> + {!session ? <form onSubmit={(event) => void login(event)}> + <label className="ps-label">{labels.apiUrl}<input className="ps-input" ref={base} type="url" required defaultValue={import.meta.env.VITE_API_BASE_URL ?? ""} disabled={busy} /></label> + <label className="ps-label">{labels.email}<input className="ps-input" ref={email} type="email" required autoComplete="username" autoCapitalize="none" spellCheck={false} disabled={busy} /></label> + <label className="ps-label">{labels.password}<input className="ps-input" ref={password} type="password" required autoComplete="current-password" disabled={busy} /></label> + <button className="ps-button" disabled={busy}>{t("cloud.openSubmit")}</button> + </form> : <> + <label className="ps-label">{labels.vaultId}<select className="ps-input" value={boxId} onChange={(event) => setBoxId(event.target.value)}> + {boxes.map((box) => <option key={box.id} value={box.id}>{box.name}</option>)} + </select></label> + <button type="button" className="ps-button ps-button--secondary" onClick={() => void logout().catch(() => setFailed(true))}>{t("session.logout")}</button> + {boxId && <CloudBoxPanel key={boxId} session={session} boxId={boxId} split />} + </>} + {failed && <p className="ps-error" role="alert">{t("cloud.failed")}</p>} + </section>; +} diff --git a/apps/desktop/src/lib/cloud-sync.ts b/apps/desktop/src/lib/cloud-sync.ts index e0f451ff..12c53357 100644 --- a/apps/desktop/src/lib/cloud-sync.ts +++ b/apps/desktop/src/lib/cloud-sync.ts @@ -138,6 +138,15 @@ async function loginAndGetClient(config: CloudSyncConfig): Promise<ConnectClient }); assertOk(loginRes, "Login failed"); + // This legacy sync transfers plaintext. Only authoritative compatibility + // state permits it; unknown/encrypted/disabled state never reaches Connect. + const stateRes = await syncFetch(`${config.apiBaseUrl}/api/encrypted/boxes/${encodeURIComponent(config.vaultId)}`, { + credentials: "include", cache: "no-store", redirect: "error", + }); + assertOk(stateRes, "Password box mode unavailable"); + const state = await stateRes.json() as { mode: string; keyEpoch: number | null }; + if (state.mode !== "compatibility" || state.keyEpoch !== null) throw new SyncError("unknown", "Encrypted password box requires encrypted item screen"); + const tokenRes = await syncFetch(`${config.apiBaseUrl}/api/auth/connect-token`, { credentials: "include", }); diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 28ae81c4..0d168e9e 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -23,7 +23,7 @@ "@shared/*": ["../../shared/*"], "react": ["./node_modules/@types/react/index.d.ts"], "react/*": ["./node_modules/@types/react/*"], - "@passshare/connect": ["./node_modules/@passshare/connect"] + "@passshare/connect": ["../../packages/connect-js/dist/client.d.ts"] } }, "include": ["src", "../../shared/types", "../../shared/ui"], diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index eb3c7341..331593e4 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -3,15 +3,17 @@ import { fileURLToPath } from "node:url"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; import { reactResolveFromApp } from "../../shared/vite/react-resolve"; +import { cloudAssets } from "../../shared/vite/cloud-assets"; const rootDir = path.dirname(fileURLToPath(import.meta.url)); const reactResolve = reactResolveFromApp(rootDir); export default defineConfig({ - plugins: [react()], + plugins: [react(), cloudAssets(path.resolve(rootDir, "../.."))], clearScreen: false, resolve: { alias: { + "@passshare/connect": path.resolve(rootDir, "../../packages/connect-js/dist/client.js"), "@shared": path.resolve(rootDir, "../../shared"), ...reactResolve.alias, }, diff --git a/apps/mobile/src-tauri/tauri.conf.json b/apps/mobile/src-tauri/tauri.conf.json index 16519182..0898ac9f 100644 --- a/apps/mobile/src-tauri/tauri.conf.json +++ b/apps/mobile/src-tauri/tauri.conf.json @@ -21,7 +21,8 @@ "security": { "csp": { "default-src": "'self'", - "script-src": "'self'", + "script-src": "'self' 'wasm-unsafe-eval'", + "worker-src": "'self'", "style-src": "'self'", "style-src-attr": "'unsafe-inline'", "img-src": "'self' asset: http://asset.localhost data: blob:", diff --git a/apps/mobile/src/components/ItemsPanel.tsx b/apps/mobile/src/components/ItemsPanel.tsx index c0203ea8..75c22fbd 100644 --- a/apps/mobile/src/components/ItemsPanel.tsx +++ b/apps/mobile/src/components/ItemsPanel.tsx @@ -10,12 +10,21 @@ import { connectItemFromApi, connectItemToCreateBody } from "../lib/connect-item import { useAuthSession } from "../hooks/useAuthSession"; import { useItemLabels } from "../hooks/useItemLabels"; +import { CloudBoxPanel } from "@shared/cloud/CloudBoxPanel"; type Props = { vaultId: string; onBack: () => void; }; -export function ItemsPanel({ vaultId, onBack }: Props) { +export function ItemsPanel(props: Props) { + const { cloudSession } = useAuthSession(); + const { t } = useI18n(); + if (!cloudSession) return <p role="status">{t("session.loading")}</p>; + return <><button type="button" className="ps-button ps-button--secondary" onClick={props.onBack}>{t("app.vault.back")}</button> + <CloudBoxPanel key={`${cloudSession.userId}:${props.vaultId}`} session={cloudSession} boxId={props.vaultId} + compatibility={(ensureCompatibility) => <CompatibilityItemsPanel {...props} ensureCompatibility={ensureCompatibility} />} /></>; +} +function CompatibilityItemsPanel({ vaultId, onBack, ensureCompatibility }: Props & { ensureCompatibility: () => Promise<void> }) { const { t } = useI18n(); const labels = useItemLabels(); const { user, connectClient, connectLoading } = useAuthSession(); @@ -54,6 +63,7 @@ export function ItemsPanel({ vaultId, onBack }: Props) { setError(null); const active = scope.current; try { + await ensureCompatibility(); const vault = await connectClient.getVault(vaultId); if (active !== scope.current) return; setVaultName(vault.name); @@ -67,7 +77,7 @@ export function ItemsPanel({ vaultId, onBack }: Props) { } catch (err) { if (active === scope.current) setError(t("app.item.loadError")); } - }, [connectClient, vaultId, t]); + }, [connectClient, vaultId, t, ensureCompatibility]); useEffect(() => { createOperation.current = null; @@ -92,6 +102,7 @@ export function ItemsPanel({ vaultId, onBack }: Props) { const active = scope.current; createOperation.current ??= crypto.randomUUID(); try { + await ensureCompatibility(); await connectClient.createItem(vaultId, connectItemToCreateBody(values), createOperation.current); } catch (err) { const conflict = err instanceof Error && "status" in err && err.status === 409; if (active === scope.current) setError(t(conflict ? "app.item.createConflictError" : "app.item.createResultUnknown")); return; } @@ -110,7 +121,7 @@ export function ItemsPanel({ vaultId, onBack }: Props) { busyRef.current = true; setBusy(true); const active = scope.current; - try { await connectClient.editItemForm(vaultId, editingId, editingItem.version, values); } + try { await ensureCompatibility(); await connectClient.editItemForm(vaultId, editingId, editingItem.version, values); } catch { if (active === scope.current) setError(t("app.item.saveError")); return; } finally { busyRef.current = false; setBusy(false); } if (active !== scope.current) return; @@ -125,6 +136,7 @@ export function ItemsPanel({ vaultId, onBack }: Props) { if (!connectClient || busyRef.current || !window.confirm(labels.confirmDelete)) { return; } + await ensureCompatibility(); await connectClient.deleteItem(vaultId, itemId); await loadItems(); } diff --git a/apps/mobile/src/hooks/useAuthSession.tsx b/apps/mobile/src/hooks/useAuthSession.tsx index 4912851c..458d7c3b 100644 --- a/apps/mobile/src/hooks/useAuthSession.tsx +++ b/apps/mobile/src/hooks/useAuthSession.tsx @@ -31,7 +31,9 @@ import { } from "../lib/secure-storage"; import { bootstrapConnectSession } from "@shared/lib/connect-session"; +import { createCloudSession, type CloudSession } from "@shared/cloud/session-transport"; type AuthSessionContextValue = { + cloudSession: CloudSession | null; user: AuthUser | null; loading: boolean; connectClient: ConnectClient | null; @@ -104,6 +106,8 @@ async function buildConnectClientFromStorage(reason: string, isCurrent: () => bo export function AuthSessionProvider({ children }: { children: ReactNode }) { const { t } = useI18n(); const [user, setUser] = useState<AuthUser | null>(null); + const [cloudSession, setCloudSession] = useState<CloudSession | null>(null); + const cloudRef = useRef<CloudSession | null>(null); const [loading, setLoading] = useState(true); const [connectClient, setConnectClient] = useState<ConnectClient | null>(null); const [connectLoading, setConnectLoading] = useState(false); @@ -126,6 +130,8 @@ export function AuthSessionProvider({ children }: { children: ReactNode }) { clientRef.current = connectClient; const lock = useCallback(() => { + cloudRef.current?.dispose(); cloudRef.current = null; setCloudSession(null); + window.dispatchEvent(new Event("passshare:cloud-lock")); setLockGeneration((value) => value + 1); setUnlocked(false); generation.current += 1; @@ -194,6 +200,11 @@ export function AuthSessionProvider({ children }: { children: ReactNode }) { if (refreshFlight.current?.attempt === attempt) refreshFlight.current.generation = applied; started = applied; logoutToken.current = refreshToken; + if (currentUser && refreshToken && !cloudRef.current) { + const heldGeneration = generation.current; + cloudRef.current = createCloudSession(getApiBaseUrl(), currentUser.id, refreshToken, () => heldGeneration === generation.current && revision === sessionRevision.current); + setCloudSession(cloudRef.current); + } setUser(currentUser); clientRef.current = client; setConnectClient(client); @@ -256,6 +267,8 @@ export function AuthSessionProvider({ children }: { children: ReactNode }) { if (!isCurrent()) return; setUser(response.user); logoutToken.current = response.refreshToken; + cloudRef.current = createCloudSession(getApiBaseUrl(), response.user.id, response.refreshToken, () => started === generation.current && isSessionCurrent()); + setCloudSession(cloudRef.current); clientRef.current = client; setConnectClient(client); }, [adoptAccount, captureEpoch, lock, prepareSignIn]); @@ -282,6 +295,8 @@ export function AuthSessionProvider({ children }: { children: ReactNode }) { if (!isCurrent()) return; setUser(response.user); logoutToken.current = response.refreshToken; + cloudRef.current = createCloudSession(getApiBaseUrl(), response.user.id, response.refreshToken, () => started === generation.current && isSessionCurrent()); + setCloudSession(cloudRef.current); clientRef.current = client; setConnectClient(client); }, [adoptAccount, captureEpoch, lock, prepareSignIn]); @@ -384,6 +399,7 @@ export function AuthSessionProvider({ children }: { children: ReactNode }) { document.removeEventListener("visibilitychange", onVisibility); binding.dispose(); nativeLock.current = null; + cloudRef.current?.dispose(); cloudRef.current = null; generation.current += 1; authAttempt.current += 1; sessionRevision.current += 1; @@ -396,6 +412,7 @@ export function AuthSessionProvider({ children }: { children: ReactNode }) { const value = useMemo( () => ({ user, + cloudSession, loading, connectClient, connectLoading, @@ -412,6 +429,7 @@ export function AuthSessionProvider({ children }: { children: ReactNode }) { }), [ user, + cloudSession, loading, connectClient, connectLoading, diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index 7c7b77a9..512110cd 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "ES2022", + "types": ["vite/client"], "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", @@ -19,7 +20,10 @@ "resolveJsonModule": true, "baseUrl": ".", "paths": { - "@shared/*": ["../../shared/*"] + "@shared/*": ["../../shared/*"], + "react": ["./node_modules/@types/react/index.d.ts"], + "react/*": ["./node_modules/@types/react/*"], + "@passshare/connect": ["../../packages/connect-js/dist/client.d.ts"] } }, "include": ["src", "../../shared/types", "../../shared/ui"] diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 76c84091..ffb454a2 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -3,15 +3,17 @@ import { fileURLToPath } from "node:url"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; import { reactResolveFromApp } from "../../shared/vite/react-resolve"; +import { cloudAssets } from "../../shared/vite/cloud-assets"; const rootDir = path.dirname(fileURLToPath(import.meta.url)); const reactResolve = reactResolveFromApp(rootDir); export default defineConfig({ - plugins: [react()], + plugins: [react(), cloudAssets(path.resolve(rootDir, "../.."))], clearScreen: false, resolve: { alias: { + "@passshare/connect": path.resolve(rootDir, "../../packages/connect-js/dist/client.js"), "@shared": path.resolve(rootDir, "../../shared"), ...reactResolve.alias, }, diff --git a/documents/spec/cross-client-encrypted-items-ui.md b/documents/spec/cross-client-encrypted-items-ui.md new file mode 100644 index 00000000..5530f952 --- /dev/null +++ b/documents/spec/cross-client-encrypted-items-ui.md @@ -0,0 +1,86 @@ +# 3クライアントの暗号化項目・添付画面 + +元1163/1175の有限なUI接続。原22条件・M4全体・販売・配布の完了ではない。 +Desktop / Mobile / Browser extensionに、承認済みWebの項目編集と添付の操作を接続する。 + +## 承認と配置 + +本人730822776147148835の返信1558762789604950027(2026-10-11T08:46:49.454Z)は、 +質問1558751901053485076のa「添付の配置で進める」を選んだ。 +正規の回答証拠 `cross-client-ui-owner-answer.json` と、画像のSHA256 +`6df3698659d9460875b144d50579564b78093bcb48f80120e96f2405e86e51a9` を照合した。 +承認は3クライアントの見た目と配置だけ。料金・公開・鍵や認可の信頼条件を変更しない。 + +入力は既存ItemFormのタイトル→URL→ユーザー名→パスワード→メモ。 +添付の名前・容量・ダウンロード・追加はその下に置き、保存・取消を最後に並べる。 +Desktopは広い画面で一覧と編集を並べ、Mobileと拡張は1列にする。 +44px以上の操作領域、入力ラベル、状態通知、取消後のフォーカス復帰を共通部品で維持する。 + +## 接続と境界 + +- `shared/cloud` に既存WebのWorker・項目Runtime・共有の本人鍵/帯域外コード画面を移す。 + Webの認証とルーターは薄いアダプターとして残す。 +- 各セッションは認証済み本人・接続先・正式セッショントークンを一度束縛する。 + HTTPはCookieを送らず、リダイレクト・自動再送を行わない。Connectトークンは暗号化APIへ送らない。 + Mobileの既存secure-store復元・正式refresh経路とnative lock世代を維持する。 + 拡張は既存正式session復元を使う。Desktopは既存token-sessionログイン応答を使い、メモリだけで保持する。 +- 不明・失敗・暗号化モードは互換項目画面をマウントしない。 + 互換の表示・変更直前にも正式のモードを確認する。401/403/409は失敗で、平文へ戻さない。 +- 所有者の期待contextはセッションの本人・選択したbox・保持したepochから作る。 + 所有者bundle/envelopeから期待値を作らない。共有受信者は既存Rustのcontext選択と独立した本人/相手のpinを使う。 + 帯域外コード入力・比較を省略せず、公開stateやgrantを信頼の根拠にしない。 +- 保存は既存Rustの選択編集を使う。全JSON・URL順・添付参照・独立した版を保持する。 + 添付は公開state→同revision項目snapshot→添付の順に取得する。 + atomic CAS、409下書き保持、応答不明時の凍結と同じopaque要求の明示再送は共通Runtimeを使う。 +- 言語・一覧選択では同じ下書きを維持する。別の本人/box/epoch、ログアウト、背景、ページ終了、 + popup閉鎖、5分アイドルでworker・鍵・下書き・バッファ・object URLを破棄し、遅い応答を表示しない。 + 秘密鍵をstorage/DB/ログへ書かない。JS文字列のメモリ全域の物理消去は保証しない。 + +Desktopの`.pbox`、緊急キット復旧、保存失敗保持は既存経路を保つ。 +旧cloud-syncと1PUX取込は、平文を読む/送る前に互換モードを要求する。 +暗号化モードを旧同期へ流さない。暗号化ボックスと`.pbox`の自動同期はこの接続の対象外。 + +## 同梱アセットとCSP + +共有Viteプラグインは `frontend/cloud-assets.json` の全ソース指紋と3アセットのSHA256を確認し、 +同じバイト列を各ビルドの `cloud-crypto/` へ独立同梱する。配布アセットを再生成・手修正しない。 +正式出所はrun38103572958 / source d5cd7d79666d023b7baeeb582835892e84f387da。 +そのrunのcompleted/successとheadShaを読戻し確認した。 +Workerはアプリ自身のroot URLから読む。外部スクリプトを使わない。 +TauriとMV3のscript-srcには `'self' 'wasm-unsafe-eval'`、worker-srcには `'self'` を指定する。 +一般のunsafe-evalやCSP無効化は使わない。 + +## 拡張の自動入力 + +暗号化したボックスの自動入力・ページからの保存は未対応。 +背景の探索は暗号化ボックスを除外し、互換の探索でもパスワードを返さない。 +資格情報を30秒保持する背景キャッシュを実経路から外す。 +選択時に正式sessionの本人とモードを再確認してから取得する。 +内容スクリプトは旧リストのパスワードを使わず、取得後の一時参照を消す。 +背景/ページ終了/アカウント変更の後の応答を入力へ反映しない。 +暗号化画面のworkerや鍵を背景/contentへ渡さない。 + +## 有限検証と親に残す受入 + +2026-10-11に、Web・3クライアントのViteビルド、型検査、10言語481キーが成功。 +3クライアント×3個の同梱アセットすべてで正式manifestとSHA256が一致。 +共通項目・添付25件、新しいsession/画面寿命・背景探索15件、既存Desktop52件・Mobile18件・拡張41件が成功。 +新しい検査は別セッションの同時通信、epoch変更後の遅い応答、dispose/abort、実HTTP redirect非追従、 +401/403/409/404、互換からの暗号化切替、解除中のlock/背景/unmount、受信者の独立pin経路を扱う。 +項目・添付の検査は全名前・空ファイル・259byteの全bytes、409/応答喪失、取消・遅いFile読取りを扱う。 +これらは制御したReact/HTTP境界であり、正式PG・native画面の結果とは区別する。 + +WASM関連nextest43件(省略0)、all-targets Clippy、fmtが成功。 +既存Rust受入例を共通部品へ接続し、配布済み実WASMで全JSON・共有pin・添付最大容量/bytes・ +転送・返却バッファ消去・遅い返信・アイドル/背景/credential境界を確認した。 +最初の受入例は新しいモジュール参照とVMのrealm差で失敗し、参照/初期化/Uint8Arrayの境界を修正して成功した。 +製品の暗号実装と配布アセットは変更していない。 + +オーナーChromeでは同梱WASMの実Workerのdispatcher・generation・lock拒否がRust側の判定で成功。 +CUAでのタブ取得は37秒で時間切れとなった。画面配置・320pxやnativeの成功とは扱わない。 +自作タブ798082180は工程終了時にclosed1/gone0/failed0で閉じ、inventoryで不在を確認した。 + +別モデルの固定差分レビューは続けて行う。 +最新の統合ソースに対するroot全workspace/doctest、全CI、正式Node/PGとの3クライアント往復、 +MV3実popupのCSPと320px、Tauri/native WebViewの実保存/背景/再起動、全クライアントの復旧・失効・移行は親が正式に確認する。 +親C1 fixtureと親PGは操作していない。新DB/アカウントは作成していない。 diff --git a/documents/spec/desktop-ui.md b/documents/spec/desktop-ui.md index fb196bcb..b5313fff 100644 --- a/documents/spec/desktop-ui.md +++ b/documents/spec/desktop-ui.md @@ -148,5 +148,7 @@ cargo tauri build # 本番ビルド(MSI/DMG/AppImage) ## 関連 +- [承認済み3クライアントの暗号化項目・添付](./cross-client-encrypted-items-ui.md) — 共通Rust/WASM画面への接続と有限検証、native受入の残条件。 + - [MVP 仕様](./feature-passshare-mvp.md) - [デザインシステム](./design-system.md) diff --git a/documents/spec/extension-ui.md b/documents/spec/extension-ui.md index 366f7191..50ddadae 100644 --- a/documents/spec/extension-ui.md +++ b/documents/spec/extension-ui.md @@ -17,6 +17,10 @@ Status: Implemented (Issue [#89](https://github.com/AutoDevJapan/PassShare/issue ## Shared components +The popup uses [the shared encrypted item/attachment runtime](cross-client-encrypted-items-ui.md) +with its formal session. Encrypted autofill/save remains unsupported; background discovery excludes +encrypted boxes and retains no plaintext password cache. Packaged MV3/native acceptance is separate. + - `shared/ui/MatrixBackground` - `shared/styles/*` design tokens - `shared/i18n` keys prefixed with `extension.*` diff --git a/documents/spec/mobile-ui.md b/documents/spec/mobile-ui.md index 7f12c964..76ef959c 100644 --- a/documents/spec/mobile-ui.md +++ b/documents/spec/mobile-ui.md @@ -29,6 +29,10 @@ ## Shared components +Encrypted item/attachment mode uses [the shared cross-client runtime](cross-client-encrypted-items-ui.md) +with the formal mobile session and native lock generation. Connect remains the compatibility transport; +it is never an encrypted-mode fallback. Native acceptance remains a separate parent integration check. + - `shared/ui/ItemForm`, `ConnectItemList`, `CategoryPicker` - `shared/i18n` keys under `mobile.*` diff --git a/frontend/src/hooks/useItemLabels.ts b/frontend/src/hooks/useItemLabels.ts index 11730beb..7ccef09a 100644 --- a/frontend/src/hooks/useItemLabels.ts +++ b/frontend/src/hooks/useItemLabels.ts @@ -1,52 +1 @@ -import { useMemo } from "react"; -import { useI18n } from "@shared/i18n/I18nProvider"; -import type { ItemUiLabels } from "@shared/types/item.js"; - -export function useItemLabels(): ItemUiLabels { - const { t } = useI18n(); - - return useMemo( - () => ({ - categoryLogin: t("item.category.login"), - categoryPassword: t("item.category.password"), - categorySecureNote: t("item.category.secureNote"), - categoryCreditCard: t("item.category.creditCard"), - categoryIdentity: t("item.category.identity"), - categorySshKey: t("item.category.sshKey"), - categoryCustom: t("item.category.custom"), - fieldTitle: t("item.field.title"), - fieldUrl: t("item.field.url"), - fieldUsername: t("item.field.username"), - fieldPassword: t("item.field.password"), - fieldBody: t("item.field.body"), - fieldNotes: t("item.field.notes"), - fieldTags: t("item.field.tags"), - fieldFavorite: t("item.field.favorite"), - addHeading: t("app.item.addHeading"), - editHeading: t("app.item.editHeading"), - listHeading: t("app.item.listHeading"), - searchPlaceholder: t("app.item.searchPlaceholder"), - generatePassword: t("app.item.generatePassword"), - submitAdd: t("app.item.submitAdd"), - submitSave: t("app.item.submitSave"), - cancelEdit: t("app.item.cancelEdit"), - copyPassword: t("app.item.copyPassword"), - copyBody: t("app.item.copyBody"), - copied: t("app.item.copied"), - delete: t("app.item.delete"), - edit: t("app.item.edit"), - importCsv: t("app.item.importCsv"), - import1pux: t("app.item.import1pux"), - exportCsv: t("app.item.exportCsv"), - importSuccess: t("app.item.importSuccess"), - import1puxSuccess: t("app.item.import1puxSuccess"), - exportSuccess: t("app.item.exportSuccess"), - emptyList: t("app.item.emptyList"), - lock: t("app.item.lock"), - countSuffix: t("app.item.countSuffix"), - confirmDelete: t("app.item.confirmDelete"), - notSet: t("app.item.notSet"), - }), - [t], - ); -} +export { useItemLabels } from "@shared/cloud/useItemLabels"; diff --git a/frontend/src/lib/cloud-api.ts b/frontend/src/lib/cloud-api.ts index 63595e2c..f483e0cf 100644 --- a/frontend/src/lib/cloud-api.ts +++ b/frontend/src/lib/cloud-api.ts @@ -1,288 +1,5 @@ -import { authFetch } from "./auth-api"; -import type { AttachmentContext, ItemContext, OwnerContext } from "./cloud-worker"; - -export type BoxState = { mode: "compatibility"; keyEpoch: null } | { mode: "encrypted"; keyEpoch: number }; -export type OwnerBundle = { format: number; salt: string; wrapped: { format: number; context: OwnerContext; nonce: string; ciphertext: string } }; -export class CloudApiError extends Error { - constructor(readonly status: number, readonly code?: string) { super("Encrypted storage request failed"); } -} -const path = (boxId: string) => `/api/encrypted/boxes/${encodeURIComponent(boxId)}`; -async function request(url: string, init?: RequestInit) { - const response = await authFetch(url, { cache: "no-store", redirect: "error", ...init }); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - // Never reflect remote messages or input in errors. - const remoteCode = body?.code ?? body?.error; - const code = ["PS015", "PS055", "PS056", "PS024", "PS026"].find((known) => - typeof remoteCode === "string" && (remoteCode === known || remoteCode.startsWith(`${known}:`))); - throw new CloudApiError(response.status, code); - } - try { - const reply = await response.json(); - if (!reply || typeof reply !== "object" || Array.isArray(reply)) throw new CloudApiError(502); - return reply; - } catch { throw new CloudApiError(502); } -} -export async function getBoxState(boxId: string, signal?: AbortSignal): Promise<BoxState> { - const state = await request(path(boxId), { signal }); - if (state.mode === "compatibility" && state.keyEpoch === null) return state; - if (state.mode === "encrypted" && Number.isSafeInteger(state.keyEpoch) && state.keyEpoch > 0) return state; - throw new CloudApiError(502); -} -export async function getAttachmentState(boxId: string, signal: AbortSignal): Promise<{ stateRevision: number; keyEpoch: number }> { - const state = await request(`${path(boxId)}/attachment-state`, { signal }); - if (!fields(state, ["stateRevision", "keyEpoch"]) || - !Number.isSafeInteger(state.stateRevision) || state.stateRevision < 1 || - !Number.isSafeInteger(state.keyEpoch) || state.keyEpoch < 1) throw new CloudApiError(502); - return state; -} -export async function getOwnerBundle(boxId: string, signal?: AbortSignal): Promise<{ bundleVersion: number; bundle: OwnerBundle }> { - const reply = await request(`${path(boxId)}/owner-key`, { signal }); - if (!fields(reply, ["bundleVersion", "bundle"]) || !Number.isSafeInteger(reply.bundleVersion) || reply.bundleVersion < 1) throw new CloudApiError(502); - publicOwnerBundle(reply.bundle); - return reply; -} -export async function putOwnerBundle(boxId: string, expectedVersion: number, bundle: OwnerBundle, - currentPassword: string, signal: AbortSignal): Promise<void> { - if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 1 || expectedVersion >= Number.MAX_SAFE_INTEGER) - throw new CloudApiError(502); - publicOwnerBundle(bundle); - let body = JSON.stringify({ expectedVersion, bundle, currentPassword }); - currentPassword = ""; - try { - const reply = await request(`${path(boxId)}/owner-key`, { method: "PUT", body, signal }); - if (!fields(reply, ["bundleVersion", "bundle"]) || reply.bundleVersion !== expectedVersion + 1 || - !sameBundle(reply.bundle, bundle)) throw new CloudApiError(502); - publicOwnerBundle(reply.bundle); - } finally { body = ""; } -} -export async function setupBox(boxId: string, bundle: OwnerBundle, signal?: AbortSignal): Promise<void> { - const reply = await request(`${path(boxId)}/setup`, { method: "POST", body: `{"bundle":${bundleText(bundle)}}`, signal }); - if (reply.mode !== "encrypted" || reply.keyEpoch !== 1 || reply.bundleVersion !== 1 || - !sameBundle(reply.bundle, bundle)) throw new CloudApiError(502); -} - -// Exact public transport fields only. Rust validates policy, context and ciphertext. -function fields(value: unknown, keys: string[]): boolean { - return !!value && typeof value === "object" && !Array.isArray(value) && - Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); -} -export function bundleText(bundle: OwnerBundle): string { - if (!fields(bundle, ["format", "salt", "wrapped"]) || - !fields(bundle.wrapped, ["format", "context", "nonce", "ciphertext"]) || - !fields(bundle.wrapped.context, ["passwordBoxId", "ownerAccountId", "keyEpoch"])) throw new CloudApiError(502); - const { format, salt, wrapped } = bundle; - if (!Number.isSafeInteger(format) || !Number.isSafeInteger(wrapped.format) || - [salt, wrapped.nonce, wrapped.ciphertext].some((value) => typeof value !== "string") || - !Object.entries(wrapped.context).every(([name, value]) => name === "keyEpoch" - ? Number.isSafeInteger(value) : typeof value === "string")) - throw new CloudApiError(502); - // Serialize the complete Rust-produced public envelope uniformly. Domain - // context validation belongs to Rust; no secret or key field is projected. - return JSON.stringify(bundle, (_name, value: unknown) => value && typeof value === "object" - ? Object.fromEntries(Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)) - : value); -} -export function sameBundle(left: OwnerBundle, right: OwnerBundle): boolean { - try { return bundleText(left) === bundleText(right); } catch { return false; } -} -const pendingKey = (ownerId: string, boxId: string) => `passshare.cloud-setup.v1:${encodeURIComponent(ownerId)}:${encodeURIComponent(boxId)}`; -export function readPendingBundle(ownerId: string, boxId: string): OwnerBundle | null { - try { - const text = localStorage.getItem(pendingKey(ownerId, boxId)); - if (text === null) return null; - const bundle = JSON.parse(text) as OwnerBundle; - bundleText(bundle); - return bundle; - } catch { throw new CloudApiError(409, "PS015"); } -} -export function savePendingBundle(ownerId: string, boxId: string, bundle: OwnerBundle) { - const prior = readPendingBundle(ownerId, boxId); - if (prior && !sameBundle(prior, bundle)) throw new CloudApiError(409, "PS015"); - const text = bundleText(bundle); - try { - localStorage.setItem(pendingKey(ownerId, boxId), text); - if (localStorage.getItem(pendingKey(ownerId, boxId)) !== text) throw new CloudApiError(409, "PS015"); - } catch { throw new CloudApiError(409, "PS015"); } -} -export function clearPendingBundle(ownerId: string, boxId: string, expected: OwnerBundle) { - const pending = readPendingBundle(ownerId, boxId); - if (pending && !sameBundle(pending, expected)) throw new CloudApiError(409, "PS015"); - try { localStorage.removeItem(pendingKey(ownerId, boxId)); } - catch { throw new CloudApiError(409, "PS015"); } -} - -// Ciphertext-only session transport. These checks concern public routing fields; -// authentication of the envelope and validation of the whole DTO belong to Core. -export type EncryptedEnvelope = { format: 1; context: ItemContext; nonce: string; ciphertext: string }; -export type EncryptedItemMetadata = { itemId: string; itemVersion: number; keyEpoch: number }; -const validId = (value: unknown): value is string => typeof value === "string" && /^[a-z0-9]{26}$/.test(value); -const positiveInteger = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) > 0; - -export function validateItemContext(context: ItemContext): void { - if (!fields(context, ["passwordBoxId", "itemId", "itemVersion", "keyEpoch"]) || - !validId(context.passwordBoxId) || !validId(context.itemId) || - !positiveInteger(context.itemVersion) || !positiveInteger(context.keyEpoch)) throw new CloudApiError(502); -} -function base64Size(value: unknown, min: number, max: number): boolean { - if (typeof value !== "string" || value.length > 4 * Math.ceil(max / 3) || - !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) return false; - try { const bytes = atob(value); return bytes.length >= min && bytes.length <= max && btoa(bytes) === value; } - catch { return false; } -} -// Public file/HTTP shape only; Rust authenticates the ciphertext and trusted scope. -export function publicOwnerBundle(value: unknown): OwnerBundle { - const bundle = value as OwnerBundle; - bundleText(bundle); - const context = bundle.wrapped.context; - if (bundle.format !== 1 || bundle.wrapped.format !== 1 || !validId(context.passwordBoxId) || - !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(context.ownerAccountId) || - context.ownerAccountId === "00000000-0000-0000-0000-000000000000" || !positiveInteger(context.keyEpoch) || - !base64Size(bundle.salt, 16, 16) || !base64Size(bundle.wrapped.nonce, 24, 24) || - !base64Size(bundle.wrapped.ciphertext, 48, 48)) throw new CloudApiError(502); - return bundle; -} -export function readItemEnvelope(text: string, expected?: ItemContext): EncryptedEnvelope { - let envelope: EncryptedEnvelope; - try { - if (text.length > 1572864) throw new CloudApiError(502); - envelope = JSON.parse(text) as EncryptedEnvelope; - } catch { throw new CloudApiError(502); } - if (!fields(envelope, ["format", "context", "nonce", "ciphertext"]) || envelope.format !== 1 || - !base64Size(envelope.nonce, 24, 24) || !base64Size(envelope.ciphertext, 16, 1048592)) throw new CloudApiError(502); - validateItemContext(envelope.context); - if (expected) { - validateItemContext(expected); - if (envelope.context.keyEpoch !== expected.keyEpoch) throw new CloudApiError(409, "PS056"); - if (envelope.context.passwordBoxId !== expected.passwordBoxId || envelope.context.itemId !== expected.itemId || - envelope.context.itemVersion !== expected.itemVersion || envelope.context.keyEpoch !== expected.keyEpoch) throw new CloudApiError(502); - } - return envelope; -} -async function boundedText(response: Response, limit: number): Promise<string> { - const length = response.headers.get("Content-Length"); - if (length !== null && (!/^\d+$/.test(length) || Number(length) > limit)) throw new CloudApiError(502); - const reader = response.body?.getReader(); - if (!reader) return ""; - const parts: Uint8Array[] = []; - let size = 0; - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - size += value.byteLength; - if (size > limit) throw new CloudApiError(502); - parts.push(value); - } - const bytes = new Uint8Array(size); - let offset = 0; - for (const part of parts) { bytes.set(part, offset); offset += part.length; } - return new TextDecoder("utf-8", { fatal: true }).decode(bytes); - } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); } -} -async function itemRequest(url: string, init: RequestInit, status = 200, limit = 1572864): Promise<string> { - try { - const response = await authFetch(url, { cache: "no-store", redirect: "error", ...init }); - if (response.status !== status) { - let code: string | undefined; - try { - const body = JSON.parse(await boundedText(response, 65536)); - const remote = body?.code ?? body?.error; - code = ["PS015", "PS056"].find((known) => remote === known || - (typeof remote === "string" && remote.startsWith(`${known}:`))); - } catch { /* Remote diagnostics never leave the transport. */ } - throw new CloudApiError(response.status, code); - } - if (status === 204 && (response.headers.has("Transfer-Encoding") || - (response.headers.has("Content-Length") && response.headers.get("Content-Length") !== "0"))) throw new CloudApiError(502); - return await boundedText(response, status === 204 ? 0 : limit); - } catch (error) { - if (error instanceof CloudApiError) throw error; - throw new CloudApiError(0); - } -} -function itemsPath(boxId: string, itemId?: string): string { - if (!validId(boxId) || (itemId !== undefined && !validId(itemId))) throw new CloudApiError(502); - return `${path(boxId)}/items${itemId === undefined ? "" : `/${itemId}`}`; -} -export async function listEncryptedItems(boxId: string, keyEpoch: number, after: string | null, signal: AbortSignal): -Promise<{ items: EncryptedItemMetadata[]; nextCursor: string | null }> { - if (!positiveInteger(keyEpoch) || (after !== null && !validId(after))) throw new CloudApiError(502); - const text = await itemRequest(`${itemsPath(boxId)}${after === null ? "" : `?after=${after}`}`, { signal }); - let page: { items: EncryptedItemMetadata[]; nextCursor: string | null }; - try { page = JSON.parse(text); } catch { throw new CloudApiError(502); } - if (!fields(page, ["items", "nextCursor"]) || !Array.isArray(page.items) || page.items.length > 100) throw new CloudApiError(502); - let previous = after ?? ""; - for (const entry of page.items) { - if (!fields(entry, ["itemId", "itemVersion", "keyEpoch"]) || !validId(entry.itemId) || entry.itemId <= previous || - !positiveInteger(entry.itemVersion) || !positiveInteger(entry.keyEpoch)) throw new CloudApiError(502); - if (entry.keyEpoch !== keyEpoch) throw new CloudApiError(409, "PS056"); - previous = entry.itemId; - } - if (page.items.length === 100 ? page.nextCursor !== previous : page.nextCursor !== null) throw new CloudApiError(502); - return page; -} -export async function getEncryptedItem(expected: ItemContext, signal: AbortSignal): Promise<string> { - validateItemContext(expected); - const text = await itemRequest(itemsPath(expected.passwordBoxId, expected.itemId), { signal }); - readItemEnvelope(text, expected); - return text; -} -export async function putEncryptedItem(expected: ItemContext, expectedVersion: number, envelopeJSON: string, signal: AbortSignal): Promise<string> { - validateItemContext(expected); - if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 0 || expectedVersion >= Number.MAX_SAFE_INTEGER || - expected.itemVersion !== expectedVersion + 1) throw new CloudApiError(502); - const sent = readItemEnvelope(envelopeJSON, expected); - // Embed the exact prepared worker output, including on explicit retry. - const text = await itemRequest(itemsPath(expected.passwordBoxId, expected.itemId), { - method: "PUT", body: `{"expectedVersion":${expectedVersion},"envelope":${envelopeJSON}}`, signal, - }); - const stored = readItemEnvelope(text, expected); - if (stored.format !== sent.format || stored.nonce !== sent.nonce || stored.ciphertext !== sent.ciphertext) throw new CloudApiError(502); - return text; -} -export async function deleteEncryptedItem(expected: ItemContext, signal: AbortSignal): Promise<void> { - validateItemContext(expected); - await itemRequest(itemsPath(expected.passwordBoxId, expected.itemId), { method: "DELETE", - body: JSON.stringify({ expectedVersion: expected.itemVersion, keyEpoch: expected.keyEpoch }), signal }, 204); -} - -// The item and file reads hold the same independently obtained public revision. -export async function getAttachmentItemSnapshot(expected: ItemContext, revision: number, signal: AbortSignal): Promise<string> { - validateItemContext(expected); - if (!positiveInteger(revision)) throw new CloudApiError(502); - const text = await itemRequest(`${itemsPath(expected.passwordBoxId, expected.itemId)}/attachment-snapshot?revision=${revision}`, { signal }); - readItemEnvelope(text, expected); - return text; -} -function attachmentPath(context: AttachmentContext): string { - if (!validId(context.attachmentId) || !positiveInteger(context.attachmentVersion) || !positiveInteger(context.keyEpoch)) throw new CloudApiError(502); - return `${itemsPath(context.passwordBoxId, context.itemId)}/attachments/${context.attachmentId}`; -} -export async function getEncryptedAttachment(context: AttachmentContext, revision: number, signal: AbortSignal): Promise<string> { - if (!positiveInteger(revision)) throw new CloudApiError(502); - // Forward the opaque response to Rust for full validation and authentication. - return itemRequest(`${attachmentPath(context)}?revision=${revision}`, { signal }, 200, 1404000); -} -export type AttachmentWrite = { context: AttachmentContext; itemContext: ItemContext; revision: number; - itemEnvelope: string; expectedVersion: number; envelope: string | null }; -export async function writeEncryptedAttachment(prepared: AttachmentWrite, signal: AbortSignal): Promise<number> { - const { context, itemContext, revision, itemEnvelope, expectedVersion, envelope } = prepared; - const url = attachmentPath(context); - validateItemContext(itemContext); - if (!positiveInteger(revision) || revision > Number.MAX_SAFE_INTEGER - 2 || itemContext.itemVersion < 2 || - context.passwordBoxId !== itemContext.passwordBoxId || context.itemId !== itemContext.itemId || context.keyEpoch !== itemContext.keyEpoch || - !Number.isSafeInteger(expectedVersion) || expectedVersion < (envelope === null ? 1 : 0) || - (envelope !== null && (expectedVersion >= Number.MAX_SAFE_INTEGER || context.attachmentVersion !== expectedVersion + 1)) || - (envelope === null && context.attachmentVersion !== expectedVersion)) throw new CloudApiError(502); - readItemEnvelope(itemEnvelope, itemContext); - const body = `{"expectedStateRevision":${revision},"item":{"expectedVersion":${itemContext.itemVersion - 1},"envelope":${itemEnvelope}},"expectedVersion":${expectedVersion},${envelope === null ? `"keyEpoch":${context.keyEpoch}` : `"envelope":${envelope}`}}`; - if (new TextEncoder().encode(body).byteLength > 2 * 1024 * 1024) throw new CloudApiError(413); - const text = await itemRequest(url, { method: envelope === null ? "DELETE" : "PUT", body, signal }); - let reply: Record<string, unknown>; - try { reply = JSON.parse(text); } catch { throw new CloudApiError(502); } - if (!fields(reply, ["stateRevision", "itemVersion", "keyEpoch", envelope === null ? "deleted" : "attachmentVersion"]) || - reply.stateRevision !== revision + 2 || reply.itemVersion !== itemContext.itemVersion || reply.keyEpoch !== context.keyEpoch || - (envelope === null ? reply.deleted !== true : reply.attachmentVersion !== context.attachmentVersion)) throw new CloudApiError(502); - return revision + 2; -} +import { createCloudApi } from '@shared/cloud/cloud-api'; +import { authFetch } from './auth-api'; +export * from '@shared/cloud/cloud-api'; +export const webCloudApi = createCloudApi(authFetch); +export const { getBoxState, getAttachmentState, getOwnerBundle, putOwnerBundle, setupBox, bundleText, sameBundle, readPendingBundle, savePendingBundle, clearPendingBundle, validateItemContext, publicOwnerBundle, readItemEnvelope, listEncryptedItems, getEncryptedItem, putEncryptedItem, deleteEncryptedItem, getAttachmentItemSnapshot, getEncryptedAttachment, writeEncryptedAttachment } = webCloudApi; diff --git a/frontend/src/lib/cloud-worker.ts b/frontend/src/lib/cloud-worker.ts index 63239592..5e30f1b7 100644 --- a/frontend/src/lib/cloud-worker.ts +++ b/frontend/src/lib/cloud-worker.ts @@ -1,319 +1 @@ -// Transport only. Rust owns password policy, cryptography and owner validation. -export type OwnerContext = { passwordBoxId: string; ownerAccountId: string; keyEpoch: number }; -export type ItemContext = { passwordBoxId: string; itemId: string; itemVersion: number; keyEpoch: number }; -export type AttachmentContext = { passwordBoxId: string; itemId: string; attachmentId: string; attachmentVersion: number; keyEpoch: number }; -export type AttachmentPlaintext = { name: string; bytes: Uint8Array }; -export type AttachmentSource = { itemEnvelope: string; expectedItemContext: ItemContext; itemSnapshotRevision: number; attachmentSnapshotRevision: number }; -export type AccountContext = { accountId: string; identityVersion: number }; -export type PublicIdentity = AccountContext & { publicKey: string; keyId: string }; -export type SharedContext = { passwordBoxId: string; senderAccountId: string; recipientAccountId: string; - keyEpoch: number; senderIdentityVersion: number; recipientIdentityVersion: number; senderKeyId: string; recipientKeyId: string }; -type Reply = { version: number; requestId: number | null; generation: number; locked: boolean; identityLocked: boolean; ok: boolean; error?: string; bundle?: string; envelope?: string; item?: string; recovery?: string; publicIdentity?: string; sharedContext?: string; identityPrepared?: string; name?: string; bytes?: Uint8Array }; -type Command = "generate" | "wrap" | "unlock" | "recover" | "recoveryMaterial" | "create" | "edit" | "encrypt" | "decrypt" | - "identityGenerate" | "identityPublic" | "identityWrap" | "identityUnlock" | "identityPrepare" | "verifyPublicIdentity" | "sharingContext" | "sharedRecipientWrap" | "sharedReceiveUnlock" | "encryptAttachment" | "decryptAttachment" | "editAttachmentItem"; -const MAX_BODY = 1024 * 1024; -const MAX_ENVELOPE = 1_500_000; -const MAX_MESSAGE = MAX_BODY * 6 + 4096; -const IDLE_TIMEOUT_MS = 5 * 60 * 1000; -const utf8Size = (text: string) => new TextEncoder().encode(text).byteLength; -// A consumer may have transferred a returned buffer again. Detached/empty -// buffers must never prevent cancellation from destroying the private worker. -const wipeBytes = (bytes?: Uint8Array) => { if (bytes?.byteLength) bytes.fill(0); }; - -export class CloudWorkerError extends Error { - constructor() { super("Cloud worker unavailable or request rejected"); } -} - -export class CloudWorker { - private worker: Worker | null = null; - private generation = 0; - private requestId = 0; - private stopped = false; - private opened = false; - private identityOpened = false; - private readonly attachmentBuffers = new Set<Uint8Array>(); - private pending: { id: number; command: Command; generation: number; resolve: (reply: Reply) => void; reject: (error: CloudWorkerError) => void } | null = null; - private timer: ReturnType<typeof setTimeout> | undefined; - private idleTimer: ReturnType<typeof setTimeout> | undefined; - private lastInput: { monotonic: number; wall: number } | null = null; - private readonly inputEvents = ["pointerdown", "keydown", "wheel", "click", "beforeinput", "input", "submit"] as const; - private readonly input = (event: Event) => { - if (!event.isTrusted || document.visibilityState !== "visible" || !this.lastInput) return; - // Check the old deadline before input can extend it. Consume the first - // expired input before a React handler could reveal or submit plaintext. - if (this.expireIdle()) { event.preventDefault(); event.stopImmediatePropagation(); return; } - this.markInput(); - }; - private readonly background = () => { if (document.visibilityState === "hidden") this.dispose(); }; - private readonly leave = () => this.dispose(); - private readyResolve!: () => void; - private readyReject!: (error: CloudWorkerError) => void; - private readyReceived = false; - private closedResolve!: () => void; - readonly ready: Promise<void>; - readonly closed: Promise<void>; - get isClosed(): boolean { return this.stopped; } - get isBusy(): boolean { return this.pending !== null; } - - constructor() { - this.ready = new Promise((resolve, reject) => { this.readyResolve = resolve; this.readyReject = reject; }); - this.closed = new Promise((resolve) => { this.closedResolve = resolve; }); - // Disposal can happen before a caller starts awaiting startup. - void this.ready.catch(() => {}); - try { - this.worker = new Worker("/cloud-crypto/boot.mjs", { type: "module" }); - this.worker.onmessage = (event: MessageEvent<unknown>) => this.receive(event.data); - this.worker.onerror = () => this.dispose(); - this.worker.onmessageerror = () => this.dispose(); - for (const type of this.inputEvents) window.addEventListener(type, this.input, { capture: true, passive: false }); - document.addEventListener("visibilitychange", this.background); - for (const type of ["pagehide", "passshare:cloud-lock"]) window.addEventListener(type, this.leave); - this.armTimeout(); - } catch { this.dispose(); } - } - - private armTimeout() { - clearTimeout(this.timer); - this.timer = setTimeout(() => this.dispose(), 60_000); - } - - private markInput() { - this.lastInput = { monotonic: performance.now(), wall: Date.now() }; - this.armIdleTimeout(); - } - - private armIdleTimeout() { - clearTimeout(this.idleTimer); - if (!this.lastInput) return; - const elapsed = Math.max(performance.now() - this.lastInput.monotonic, Date.now() - this.lastInput.wall); - this.idleTimer = setTimeout(() => { - // A timer may run early; preserve the deadline rather than renew it. - if (!this.expireIdle()) this.armIdleTimeout(); - }, Math.max(1, IDLE_TIMEOUT_MS - elapsed)); - } - - private expireIdle(): boolean { - if (!this.lastInput) return this.stopped; - const monotonic = performance.now() - this.lastInput.monotonic; - const wall = Date.now() - this.lastInput.wall; - // Some platforms pause the monotonic clock during sleep. Either elapsed - // clock expires the session; a backwards clock change also requires unlock. - if (monotonic >= IDLE_TIMEOUT_MS || wall >= IDLE_TIMEOUT_MS || monotonic < 0 || wall < 0) { - this.dispose(); - lockCloudUi(); - return true; - } - return false; - } - - private receive(data: unknown) { - let binary: Uint8Array | undefined; - // Wipe transferred plaintext even when a cancelled/terminated worker emits late. - if (data && typeof data === "object" && "bytes" in data && data.bytes instanceof Uint8Array && data.bytes.buffer instanceof ArrayBuffer) binary = data.bytes; - if (this.stopped || this.expireIdle()) { wipeBytes(binary); return; } - let reply: Reply; - try { - if (binary) { - if (Object.keys(data as object).length !== 2 || !("message" in (data as object)) || binary.byteLength > MAX_BODY || binary.buffer.byteLength !== binary.byteLength) throw new CloudWorkerError(); - data = (data as { message: unknown }).message; - } - if (typeof data !== "string" || data.length > MAX_MESSAGE || utf8Size(data) > MAX_MESSAGE) throw new CloudWorkerError(); - reply = JSON.parse(data) as Reply; - if (!reply || typeof reply !== "object" || Array.isArray(reply) || - Object.keys(reply).some((key) => !["version", "requestId", "generation", "locked", "identityLocked", "ok", "error", "bundle", "envelope", "item", "recovery", "publicIdentity", "sharedContext", "identityPrepared", "name"].includes(key)) || - (reply.requestId !== null && (!Number.isSafeInteger(reply.requestId) || reply.requestId <= 0)) || - (reply.error !== undefined && !["PS051: Invalid encrypted data", "PS057: Password box is locked", "PS057: Account identity is locked", - "PS058: Stale worker generation", "PS059: Password box is already open"].includes(reply.error)) || - (reply.bundle !== undefined && (typeof reply.bundle !== "string" || utf8Size(reply.bundle) > 2048)) || - (reply.envelope !== undefined && (typeof reply.envelope !== "string" || utf8Size(reply.envelope) > MAX_ENVELOPE)) || - (reply.item !== undefined && (typeof reply.item !== "string" || utf8Size(reply.item) > MAX_BODY)) || - (reply.recovery !== undefined && (typeof reply.recovery !== "string" || utf8Size(reply.recovery) > 4096)) || - (reply.publicIdentity !== undefined && (typeof reply.publicIdentity !== "string" || utf8Size(reply.publicIdentity) > 512)) || - (reply.sharedContext !== undefined && (typeof reply.sharedContext !== "string" || utf8Size(reply.sharedContext) > 1024)) || - (reply.identityPrepared !== undefined && (typeof reply.identityPrepared !== "string" || utf8Size(reply.identityPrepared) > 8192)) || - (reply.name !== undefined && (typeof reply.name !== "string" || utf8Size(reply.name) > 4096)) || - ((reply.name !== undefined) !== (binary !== undefined))) throw new CloudWorkerError(); - if (reply.version !== 1 || !Number.isSafeInteger(reply.generation) || reply.generation < 0 || - typeof reply.locked !== "boolean" || typeof reply.identityLocked !== "boolean" || typeof reply.ok !== "boolean") throw new CloudWorkerError(); - } catch { wipeBytes(binary); this.dispose(); return; } - if (binary) this.attachmentBuffers.add(binary); - if (!this.readyReceived) { - if (reply.requestId !== null || reply.generation !== 0 || !reply.locked || !reply.identityLocked || !reply.ok || - reply.error !== undefined || reply.bundle !== undefined || reply.envelope !== undefined || reply.item !== undefined || reply.recovery !== undefined || reply.publicIdentity !== undefined || reply.sharedContext !== undefined || reply.identityPrepared !== undefined || reply.name !== undefined) { this.dispose(); return; } - this.readyReceived = true; - clearTimeout(this.timer); - this.readyResolve(); - return; - } - const pending = this.pending; - if (!pending || reply.requestId !== pending.id) { this.dispose(); return; } - const opens = ["generate", "unlock", "recover", "sharedReceiveUnlock"].includes(pending.command); - const opensIdentity = ["identityGenerate", "identityUnlock", "identityPrepare"].includes(pending.command); - const payload = pending.command === "identityPrepare" ? "identityPrepared" : pending.command === "sharingContext" ? "sharedContext" : ["identityGenerate", "identityUnlock", "identityPublic", "verifyPublicIdentity"].includes(pending.command) ? "publicIdentity" : - pending.command === "decryptAttachment" ? "name" : pending.command === "recoveryMaterial" ? "recovery" : ["wrap", "identityWrap"].includes(pending.command) ? "bundle" : pending.command === "decrypt" ? "item" : - ["create", "edit", "editAttachmentItem", "encrypt", "sharedRecipientWrap", "encryptAttachment"].includes(pending.command) ? "envelope" : null; - if ((reply.ok ? reply.error !== undefined : typeof reply.error !== "string") || - (["bundle", "envelope", "item", "recovery", "publicIdentity", "sharedContext", "identityPrepared", "name"] as const).some((key) => - reply.ok && key === payload ? typeof reply[key] !== "string" : reply[key] !== undefined)) { this.dispose(); return; } - const expected = pending.generation + (reply.ok && (opens || opensIdentity) ? 1 : 0); - if (reply.generation !== expected || reply.locked !== !(this.opened || (reply.ok && opens)) || - reply.identityLocked !== !(this.identityOpened || (reply.ok && opensIdentity))) { this.dispose(); return; } - this.pending = null; - clearTimeout(this.timer); - this.generation = reply.generation; - if (reply.ok && opens) this.opened = true; - if (reply.ok && opensIdentity) this.identityOpened = true; - // Only the first private handle starts the deadline. Later crypto/HTTP - // traffic, including opening the other handle, never extends it. - if (reply.ok && (opens || opensIdentity) && !this.lastInput) this.markInput(); - if (binary) reply.bytes = binary; - if (reply.ok) pending.resolve(reply); - else pending.reject(new CloudWorkerError()); - } - - private async send(command: Command, fields: Record<string, string>, bytes?: Uint8Array): Promise<Reply> { - try { - await this.ready; - if (this.stopped || this.expireIdle() || !this.worker || this.pending) throw new CloudWorkerError(); - // Opening an already-open handle is a caller error, not a reason to - // destroy the valid key or decrypted draft. Rust also rejects replacement. - if (this.opened && ["generate", "unlock", "recover", "sharedReceiveUnlock"].includes(command)) throw new CloudWorkerError(); - if (this.identityOpened && ["identityGenerate", "identityUnlock", "identityPrepare"].includes(command)) throw new CloudWorkerError(); - for (const key of ["item", "values", "envelope", "context", "expectedAttachmentContext", "name", "attachmentSource"] as const) { - const limit = key === "attachmentSource" ? 2_000_000 : key === "envelope" ? MAX_ENVELOPE : ["context", "expectedAttachmentContext"].includes(key) ? 512 : key === "name" ? 4096 : MAX_BODY; - if (key in fields && (typeof fields[key] !== "string" || utf8Size(fields[key]) > limit)) throw new CloudWorkerError(); - } - if (bytes && (!(bytes.buffer instanceof ArrayBuffer) || bytes.byteLength > MAX_BODY)) throw new CloudWorkerError(); - if (this.requestId >= Number.MAX_SAFE_INTEGER) throw new CloudWorkerError(); - const id = ++this.requestId; - return new Promise((resolve, reject) => { - this.pending = { id, command, generation: this.generation, resolve, reject }; - this.armTimeout(); - try { - const message = JSON.stringify({ version: 1, requestId: id, command, expectedGeneration: this.generation, ...fields }); - if (utf8Size(message) > MAX_MESSAGE) throw new CloudWorkerError(); - if (bytes) this.worker!.postMessage({ message, bytes }, [bytes.buffer as ArrayBuffer]); - else this.worker!.postMessage(message); - } catch { this.dispose(); } - }); - } finally { wipeBytes(bytes); for (const key of ["values", "item", "password", "identitySecret", "verificationCode", "name"]) if (key in fields) fields[key] = ""; } - } - - generate(owner: OwnerContext): Promise<void> { - return this.send("generate", { owner: JSON.stringify(owner) }).then(() => {}); - } - wrap(password: string): Promise<string> { - return this.send("wrap", { password }).then((reply) => { - if (typeof reply.bundle !== "string") { this.dispose(); throw new CloudWorkerError(); } - return reply.bundle; - }); - } - unlock(owner: OwnerContext, bundle: string, password: string): Promise<void> { - return this.send("unlock", { owner: JSON.stringify(owner), bundle, password }).then(() => {}); - } - // Client-only material: callers must separate and clear the returned code. - createRecoveryMaterial(): Promise<string> { - return this.send("recoveryMaterial", {}).then((reply) => reply.recovery!); - } - recover(owner: OwnerContext, bundle: string, recoveryCode: string): Promise<void> { - return this.send("recover", { owner: JSON.stringify(owner), bundle, password: recoveryCode }).then(() => {}); - } - createItem(valuesJSON: string): Promise<string> { - return this.send("create", { values: valuesJSON }).then((reply) => reply.envelope!); - } - editItem(originalFullJSON: string, valuesJSON: string, nextItemContext: ItemContext): Promise<string> { - return this.send("edit", { item: originalFullJSON, values: valuesJSON, context: JSON.stringify(nextItemContext) }).then((reply) => reply.envelope!); - } - editAttachmentItem(originalFullJSON: string, valuesJSON: string, nextItemContext: ItemContext): Promise<string> { - return this.send("editAttachmentItem", { item: originalFullJSON, values: valuesJSON, context: JSON.stringify(nextItemContext) }).then((reply) => reply.envelope!); - } - encrypt(itemJSON: string, context: ItemContext): Promise<string> { - return this.send("encrypt", { item: itemJSON, context: JSON.stringify(context) }).then((reply) => reply.envelope!); - } - decrypt(envelopeJSON: string, context: ItemContext): Promise<string> { - return this.send("decrypt", { envelope: envelopeJSON, context: JSON.stringify(context) }).then((reply) => reply.item!); - } - // Takes ownership of input bytes. Context comes from authenticated item references - // and the same revision as the ciphertext fetch; never infer it from the envelope. - encryptAttachment(name: string, input: Uint8Array | ArrayBuffer, expectedAttachmentContext: AttachmentContext): Promise<string> { - if (!(input instanceof Uint8Array) && !(input instanceof ArrayBuffer)) return Promise.reject(new CloudWorkerError()); - const view = input instanceof ArrayBuffer ? new Uint8Array(input) : input; - if (!(view.buffer instanceof ArrayBuffer) || view.byteLength > MAX_BODY) { wipeBytes(view); return Promise.reject(new CloudWorkerError()); } - const bytes = view.byteOffset === 0 && view.byteLength === view.buffer.byteLength ? view : view.slice(); - if (bytes !== view) wipeBytes(view); - try { - return this.send("encryptAttachment", { name, expectedAttachmentContext: JSON.stringify(expectedAttachmentContext) }, bytes).then((r) => r.envelope!); - } catch { wipeBytes(bytes); return Promise.reject(new CloudWorkerError()); } - } - decryptAttachment(envelope: string, expectedAttachmentContext: AttachmentContext, attachmentSource: AttachmentSource): Promise<AttachmentPlaintext> { - return this.send("decryptAttachment", { envelope, expectedAttachmentContext: JSON.stringify(expectedAttachmentContext), attachmentSource: JSON.stringify(attachmentSource) }).then((r) => { - if (this.stopped || this.expireIdle() || r.generation !== this.generation || !r.bytes) { wipeBytes(r.bytes); throw new CloudWorkerError(); } - return { name: r.name!, bytes: r.bytes }; - }); - } - releaseAttachment(plaintext: AttachmentPlaintext) { wipeBytes(plaintext.bytes); this.attachmentBuffers.delete(plaintext.bytes); plaintext.name = ""; } - - generateIdentity(context: AccountContext): Promise<string> { - return this.send("identityGenerate", { accountContext: JSON.stringify(context) }).then((r) => r.publicIdentity!); - } - publicIdentity(): Promise<string> { return this.send("identityPublic", {}).then((r) => r.publicIdentity!); } - prepareIdentity(values: string, identitySecret: string): Promise<{ publicIdentity: string; record: string; requiresCreate: boolean; hasPending: boolean }> { - return this.send("identityPrepare", { values, identitySecret }).then((r) => { - const prepared = JSON.parse(r.identityPrepared!); - if (!prepared || Object.keys(prepared).length !== 4 || typeof prepared.publicIdentity !== "string" || - typeof prepared.record !== "string" || typeof prepared.requiresCreate !== "boolean" || typeof prepared.hasPending !== "boolean") { - this.dispose(); throw new CloudWorkerError(); - } - return prepared; - }); - } - wrapIdentity(identitySecret: string): Promise<string> { - return this.send("identityWrap", { identitySecret }).then((r) => r.bundle!); - } - unlockIdentity(context: AccountContext, expectedKeyId: string, bundle: string, identitySecret: string): Promise<string> { - return this.send("identityUnlock", { accountContext: JSON.stringify(context), expectedKeyId, bundle, identitySecret }).then((r) => r.publicIdentity!); - } - verifyPublicIdentity(context: AccountContext, publicIdentity: string, verificationCode: string): Promise<string> { - return this.send("verifyPublicIdentity", { accountContext: JSON.stringify(context), publicIdentity, verificationCode }).then((r) => r.publicIdentity!); - } - wrapForRecipient(trustedRecipientPublic: string, context: SharedContext): Promise<string> { - return this.send("sharedRecipientWrap", { trustedRecipientPublic, sharedContext: JSON.stringify(context) }).then((r) => r.envelope!); - } - sharingContext(selectionJSON: string): Promise<SharedContext> { - return this.send("sharingContext", { values: selectionJSON }).then((r) => JSON.parse(r.sharedContext!) as SharedContext); - } - receiveShared(envelope: string, trustedSenderPublic: string, context: SharedContext): Promise<void> { - return this.send("sharedReceiveUnlock", { envelope, trustedSenderPublic, sharedContext: JSON.stringify(context) }).then(() => {}); - } - - // Immediate invalidation; termination does not promise a memory-wide wipe. - dispose() { - if (this.stopped) return; - this.stopped = true; - this.opened = false; - this.identityOpened = false; - for (const bytes of this.attachmentBuffers) wipeBytes(bytes); - this.attachmentBuffers.clear(); - clearTimeout(this.timer); - clearTimeout(this.idleTimer); - this.lastInput = null; - for (const type of this.inputEvents) window.removeEventListener(type, this.input, true); - document.removeEventListener("visibilitychange", this.background); - for (const type of ["pagehide", "passshare:cloud-lock"]) window.removeEventListener(type, this.leave); - if (this.worker) { - this.worker.onmessage = null; - this.worker.onerror = null; - this.worker.onmessageerror = null; - this.worker.terminate(); - this.worker = null; - } - this.pending?.reject(new CloudWorkerError()); - this.pending = null; - this.readyReject(new CloudWorkerError()); - this.generation = 0; - this.closedResolve(); - } -} - -// Shell logout invalidates secrets before any HTTP request, even if it fails. -export function lockCloudUi() { window.dispatchEvent(new Event("passshare:cloud-lock")); } +export * from "@shared/cloud/cloud-worker"; diff --git a/frontend/src/lib/connect-items.ts b/frontend/src/lib/connect-items.ts index 3528a0b0..7e916374 100644 --- a/frontend/src/lib/connect-items.ts +++ b/frontend/src/lib/connect-items.ts @@ -1,93 +1 @@ -import type { Item } from "@passshare/connect"; -import type { ConnectItem, ItemCategory, ItemFormValues } from "@shared/types/item.js"; -import { parseTagsInput } from "@shared/types/item.js"; - -function fieldValue(item: Item, purpose?: string, label?: string): string { - const fields = item.fields ?? []; - const match = fields.find( - (field) => - (purpose && field.purpose?.toUpperCase() === purpose) || - (label && field.label?.toLowerCase() === label.toLowerCase()), - ); - return match?.value ?? ""; -} - -export function connectItemFromApi(item: Item): ConnectItem { - const category = (item.category ?? "LOGIN") as ItemCategory; - const primaryUrl = item.urls?.find((url) => url.primary) ?? item.urls?.[0]; - - return { - id: item.id, - title: item.title, - category, - tags: item.tags ?? [], - favorite: Boolean(item.favorite), - url: primaryUrl?.href ?? null, - username: fieldValue(item, "USERNAME"), - password: fieldValue(item, "PASSWORD"), - body: fieldValue(item, undefined, "notes") || fieldValue(item, undefined, "body"), - notes: fieldValue(item, undefined, "notes"), - revision: item.version, - updatedAt: item.updatedAt ?? null, - }; -} - -export function connectItemToCreateBody(values: ItemFormValues, original?: Item): Record<string, unknown> { - const tags = original && values.tags === (original.tags ?? []).join(", ") - ? original.tags ?? [] : parseTagsInput(values.tags); - const fields: Array<Record<string, unknown>> = (original?.fields ?? []).map((field) => ({ ...field })); - - // Update only the fields exposed by the form. Imported fields, sections and - // metadata must survive a title/password edit, including their original IDs. - function updateField(label: string, value: string, purpose?: string) { - const index = fields.findIndex((field) => purpose - ? typeof field.purpose === "string" && field.purpose.toUpperCase() === purpose - : typeof field.label === "string" && field.label.toLowerCase() === label); - if (index >= 0) { - fields[index] = { ...fields[index], value }; - } else if (value || !original) { - fields.push({ label, type: purpose === "PASSWORD" ? "CONCEALED" : "STRING", ...(purpose ? { purpose } : {}), value }); - } - } - - if (values.category === "LOGIN") { - updateField("username", values.username, "USERNAME"); - updateField("password", values.password, "PASSWORD"); - } else if (values.category === "PASSWORD") { - updateField("password", values.password, "PASSWORD"); - } else { - const notesField = original?.fields?.find((field) => field.label.toLowerCase() === "notes"); - const bodyField = original?.fields?.find((field) => field.label.toLowerCase() === "body"); - const bodyLabel = notesField?.value || !bodyField ? "notes" : "body"; - updateField(bodyLabel, values.body); - } - - if (values.category === "LOGIN" || values.category === "PASSWORD") { - if (values.notes || original?.fields?.some((field) => field.label.toLowerCase() === "notes")) { - updateField("notes", values.notes); - } - } - - const body: Record<string, unknown> = { - ...original, - title: values.title, - category: values.category, - fields, - tags, - favorite: values.favorite, - }; - - if (values.category === "LOGIN") { - const urls = (original?.urls ?? []).map((url) => ({ ...url })); - const primaryIndex = Math.max(0, urls.findIndex((url) => url.primary)); - if (values.url.trim()) { - if (urls.length) urls[primaryIndex] = { ...urls[primaryIndex], href: values.url.trim() }; - else urls.push({ href: values.url.trim(), primary: true, label: "" }); - } else if (urls.length) { - urls.splice(primaryIndex, 1); - } - body.urls = urls; - } - - return body; -} +export * from "@shared/cloud/connect-items"; diff --git a/frontend/src/lib/sharing-api.ts b/frontend/src/lib/sharing-api.ts index b6cbac2c..5ed5710a 100644 --- a/frontend/src/lib/sharing-api.ts +++ b/frontend/src/lib/sharing-api.ts @@ -1,154 +1,5 @@ -// Public routing, ciphertext and independently held public pins only. -// Cryptography, fingerprint comparison and membership policy run in Rust. -import { authFetch } from "./auth-api"; -import { CloudApiError } from "./cloud-api"; -import type { PublicIdentity, SharedContext } from "./cloud-worker"; - -export type IdentityRecord = { bundleVersion: number; identity: PublicIdentity; bundle: unknown }; -export type Member = { accountId: string; role: string; membershipConfirmed: boolean; keyGranted: boolean }; -export type Snapshot = { stateRevision: number; keyEpoch: number; ownerAccountId: string; ownerBundleVersion: number; - memberCount: number; liveItemCount: number; grantCount: number; liveAttachmentCount: number; liveAttachmentBytes: number }; -const boxPath = (id: string) => `/api/encrypted/boxes/${encodeURIComponent(id)}`; -const identityPath = "/api/encrypted/account-identity"; -const exact = (value: unknown, keys: string[]): boolean => !!value && typeof value === "object" && !Array.isArray(value) && - Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); -const positive = (value: unknown): boolean => Number.isSafeInteger(value) && Number(value) > 0; -export function publicText(value: unknown): string { - return JSON.stringify(value, (_key, entry: unknown) => entry && typeof entry === "object" && !Array.isArray(entry) - ? Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : entry); -} -async function request(url: string, signal: AbortSignal, body?: unknown): Promise<any> { - const response = await authFetch(url, { signal, cache: "no-store", redirect: "error", - ...(body === undefined ? {} : { method: "PUT", body: JSON.stringify(body) }) }); - if (response.status !== 200) throw new CloudApiError(response.status); - const reader = response.body?.getReader(); - if (!reader) throw new CloudApiError(502); - const decoder = new TextDecoder("utf-8", { fatal: true }); - let size = 0; - let text = ""; - try { - for (;;) { - const { value, done } = await reader.read(); - if (done) break; - size += value.byteLength; - if (size > 65536) throw new CloudApiError(502); - text += decoder.decode(value, { stream: true }); - } - text += decoder.decode(); - } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); } - try { return JSON.parse(text); } catch { throw new CloudApiError(502); } -} -function record(value: IdentityRecord): IdentityRecord { - if (!exact(value, ["bundleVersion", "identity", "bundle"]) || !positive(value.bundleVersion) || - !exact(value.identity, ["accountId", "identityVersion", "publicKey", "keyId"]) || - !positive(value.identity.identityVersion) || [value.identity.accountId, value.identity.publicKey, value.identity.keyId] - .some((entry) => typeof entry !== "string") || publicText(value.bundle).length > 2048) throw new CloudApiError(502); - return value; -} -export async function getIdentity(signal: AbortSignal): Promise<IdentityRecord | null> { - try { return record(await request(identityPath, signal)); } - catch (error) { if (error instanceof CloudApiError && error.status === 404) return null; throw error; } -} -export async function createIdentity(prepared: IdentityRecord, signal: AbortSignal): Promise<IdentityRecord> { - record(prepared); - const reply = record(await request(identityPath, signal, { expectedVersion: 0, identity: prepared.identity, bundle: prepared.bundle })); - if (reply.bundleVersion !== 1 || publicText(reply) !== publicText(prepared)) throw new CloudApiError(409); - return reply; -} -export async function getMemberIdentity(boxId: string, accountId: string, signal: AbortSignal): Promise<PublicIdentity> { - const reply = await request(`${boxPath(boxId)}/member-identities/${encodeURIComponent(accountId)}`, signal); - if (!exact(reply, ["identity"])) throw new CloudApiError(502); - const value = reply.identity; - if (!exact(value, ["accountId", "identityVersion", "publicKey", "keyId"]) || value.accountId !== accountId || - !positive(value.identityVersion) || typeof value.publicKey !== "string" || typeof value.keyId !== "string") throw new CloudApiError(502); - return value; -} -export async function getSnapshot(boxId: string, signal: AbortSignal): Promise<Snapshot> { - const value = await request(`${boxPath(boxId)}/sharing-snapshot`, signal); - const keys = ["stateRevision", "keyEpoch", "ownerAccountId", "ownerBundleVersion", "memberCount", "liveItemCount", "grantCount"]; - // The official API omits both attachment counters only for an empty set. - // Keep strict shape validation, including rejection of a missing peer counter. - const hasAttachments = !!value && Object.hasOwn(value, "liveAttachmentCount"); - if (!exact(value, hasAttachments ? [...keys, "liveAttachmentCount", "liveAttachmentBytes"] : keys) || - ![value.stateRevision, value.keyEpoch, value.ownerBundleVersion, value.memberCount].every(positive) || - ![value.liveItemCount, value.grantCount].every((v) => Number.isSafeInteger(v) && v >= 0) || typeof value.ownerAccountId !== "string" || - (hasAttachments && (!positive(value.liveAttachmentCount) || value.liveAttachmentCount > 1024 || - !positive(value.liveAttachmentBytes) || value.liveAttachmentBytes > 1073741824))) throw new CloudApiError(502); - return { ...value, liveAttachmentCount: value.liveAttachmentCount ?? 0, liveAttachmentBytes: value.liveAttachmentBytes ?? 0 }; -} -export async function getSnapshotMembers(boxId: string, snapshot: Snapshot, signal: AbortSignal): Promise<Member[]> { - const members: Member[] = []; - let after: string | null = null; - do { - const page = await request(`${boxPath(boxId)}/sharing-snapshot/members?revision=${snapshot.stateRevision}${after === null ? "" : `&after=${encodeURIComponent(after)}`}`, signal); - if (!exact(page, ["stateRevision", "members", "nextCursor"]) || page.stateRevision !== snapshot.stateRevision || - !Array.isArray(page.members) || page.members.length > 100) throw new CloudApiError(502); - let previous = after ?? ""; - for (const member of page.members) { - if (!exact(member, ["accountId", "role", "membershipConfirmed", "keyGranted"]) || - typeof member.accountId !== "string" || member.accountId <= previous || typeof member.role !== "string" || - typeof member.membershipConfirmed !== "boolean" || typeof member.keyGranted !== "boolean") throw new CloudApiError(502); - previous = member.accountId; - members.push(member); - } - if ((page.members.length === 100 ? page.nextCursor !== previous : page.nextCursor !== null) || - members.length > snapshot.memberCount || members.length > 1000) throw new CloudApiError(502); - after = page.nextCursor; - } while (after !== null); - if (members.length !== snapshot.memberCount) throw new CloudApiError(502); - return members; -} -export async function putGrant(boxId: string, revision: number, context: SharedContext, envelope: unknown, signal: AbortSignal): Promise<void> { - const reply = await request(`${boxPath(boxId)}/grants/${encodeURIComponent(context.recipientAccountId)}`, signal, - { expectedStateRevision: revision, keyEpoch: context.keyEpoch, expectedVersion: 0, envelope }); - if (!exact(reply, ["grantVersion", "keyEpoch", "stateRevision", "envelope"]) || reply.grantVersion !== 1 || - reply.keyEpoch !== context.keyEpoch || !positive(reply.stateRevision) || reply.stateRevision < revision || - publicText(reply.envelope) !== publicText(envelope)) throw new CloudApiError(502); -} -export async function getSelfGrant(boxId: string, context: SharedContext, signal: AbortSignal): Promise<string> { - const reply = await request(`${boxPath(boxId)}/grants/me`, signal); - if (!exact(reply, ["grantVersion", "keyEpoch", "envelope"]) || reply.grantVersion !== 1 || reply.keyEpoch !== context.keyEpoch || - !exact(reply.envelope, ["format", "suite", "context", "enc", "ciphertext"]) || - publicText(reply.envelope.context) !== publicText(context) || publicText(reply.envelope).length > 2048) throw new CloudApiError(502); - return JSON.stringify(reply.envelope); -} - -// Three separate, account-scoped stores. Neither bundle nor a server reply -// supplies an expected context/key ID. Pins contain public descriptors only. -const ownKey = (account: string) => `passshare.identity-own-pin.v1:${encodeURIComponent(account)}`; -const peerKey = (account: string, peer: string) => `passshare.identity-peer-pin.v1:${encodeURIComponent(account)}:${encodeURIComponent(peer)}`; -const pendingKey = (account: string) => `passshare.identity-pending.v1:${encodeURIComponent(account)}`; -function read(key: string): unknown | null { - try { const text = localStorage.getItem(key); return text === null ? null : JSON.parse(text); } - catch { throw new CloudApiError(409); } -} -function write(key: string, value: unknown) { - const text = publicText(value); - try { localStorage.setItem(key, text); if (localStorage.getItem(key) !== text) throw new CloudApiError(409); } - catch { throw new CloudApiError(409); } -} -export function readOwnPin(account: string): PublicIdentity | null { return read(ownKey(account)) as PublicIdentity | null; } -// Call only with the pin returned by Rust identityPrepare after private-key -// restoration and an independently entered code (or the existing own pin). -export function savePreparedOwnPin(account: string, identity: PublicIdentity) { - if (identity.accountId !== account) throw new CloudApiError(409); - write(ownKey(account), identity); -} -export function readPeerPin(account: string, peer: string): PublicIdentity | null { return read(peerKey(account, peer)) as PublicIdentity | null; } -export function savePeerPin(account: string, peer: string, identity: PublicIdentity) { - if (identity.accountId !== peer) throw new CloudApiError(409); - write(peerKey(account, peer), identity); -} -export function readPendingIdentity(account: string): IdentityRecord | null { - const value = read(pendingKey(account)); return value === null ? null : record(value as IdentityRecord); -} -export function savePendingIdentity(account: string, value: IdentityRecord) { - const previous = readPendingIdentity(account); - if (value.identity.accountId !== account || (previous && publicText(previous) !== publicText(value))) throw new CloudApiError(409); - write(pendingKey(account), record(value)); -} -export function clearPendingIdentity(account: string, expected: IdentityRecord) { - const value = readPendingIdentity(account); - if (value && publicText(value) !== publicText(expected)) throw new CloudApiError(409); - localStorage.removeItem(pendingKey(account)); -} +import { createSharingApi } from '@shared/cloud/sharing-api'; +import { authFetch } from './auth-api'; +export * from '@shared/cloud/sharing-api'; +export const webSharingApi = createSharingApi(authFetch); +export const { publicText, getIdentity, createIdentity, getMemberIdentity, getSnapshot, getSnapshotMembers, putGrant, getSelfGrant, readOwnPin, savePreparedOwnPin, readPeerPin, savePeerPin, readPendingIdentity, savePendingIdentity, clearPendingIdentity } = webSharingApi; diff --git a/frontend/src/pages/app/CloudSharingPanel.tsx b/frontend/src/pages/app/CloudSharingPanel.tsx index 3a17538b..31616eef 100644 --- a/frontend/src/pages/app/CloudSharingPanel.tsx +++ b/frontend/src/pages/app/CloudSharingPanel.tsx @@ -1,255 +1,8 @@ -import { useLayoutEffect, useRef, useState, type FormEvent } from "react"; -import { useI18n } from "@shared/i18n/I18nProvider"; -import { listVaultMembers, type VaultMember } from "../../lib/auth-api"; -import { CloudWorker, type PublicIdentity, type OwnerContext } from "../../lib/cloud-worker"; -import { CloudApiError, getBoxState } from "../../lib/cloud-api"; -import { clearPendingIdentity, createIdentity, getIdentity, getMemberIdentity, getSelfGrant, getSnapshot, - getSnapshotMembers, publicText, putGrant, readOwnPin, readPeerPin, readPendingIdentity, - savePreparedOwnPin, savePeerPin, savePendingIdentity, type IdentityRecord } from "../../lib/sharing-api"; -import "./cloud-sharing.css"; - -// This component only renders and sequences transport. Rust compares trust -// codes, selects shared context and authenticates the complete item/key data. -export function CloudSharingPanel({ worker, scope, recipient = false, onReceive, onLock }: { - worker: CloudWorker; scope: OwnerContext; recipient?: boolean; - onReceive?: () => void; onLock: () => void; -}) { - const { t } = useI18n(); - const [identity, setIdentity] = useState<PublicIdentity | null>(null); - const [members, setMembers] = useState<VaultMember[]>([]); - const [peer, setPeer] = useState(""); - const [trusted, setTrusted] = useState<PublicIdentity | null>(null); - const [busy, setBusy] = useState(false); - const [failed, setFailed] = useState(false); - const [complete, setComplete] = useState(false); - const [ownCodeNeeded, setOwnCodeNeeded] = useState(false); - const [creating, setCreating] = useState(false); - const [ready, setReady] = useState(false); - const secretInput = useRef<HTMLInputElement>(null); - const confirmation = useRef<HTMLInputElement>(null); - const ownCode = useRef<HTMLInputElement>(null); - const peerCode = useRef<HTMLTextAreaElement>(null); - const lifetime = useRef(0); - const task = useRef<{ controller: AbortController; secret: string; code: string } | null>(null); - const loaded = useRef(false); - const account = scope.ownerAccountId; - function clearInputs() { - for (const ref of [secretInput, confirmation, ownCode]) if (ref.current) ref.current.value = ""; - if (peerCode.current) peerCode.current.value = ""; - } - function cancel() { - ++lifetime.current; - if (task.current) { task.current.secret = ""; task.current.code = ""; task.current.controller.abort(); } - task.current = null; clearInputs(); setIdentity(null); setTrusted(null); setBusy(false); setComplete(false); - worker.dispose(); onLock(); - } - useLayoutEffect(() => { - const version = ++lifetime.current; - const controller = new AbortController(); - loaded.current = false; - setReady(false); - void (async () => { - try { - const record = await getIdentity(controller.signal); - const list = await listVaultMembers(scope.passwordBoxId, controller.signal); - if (controller.signal.aborted || lifetime.current !== version || worker.isClosed) return; - setMembers(list); - const owner = list.find((m) => m.role === "owner"); - if (recipient) setPeer(owner?.userId ?? ""); - setCreating(!record && !readPendingIdentity(account)); - const pin = readOwnPin(account); - const held = record ?? readPendingIdentity(account); - setOwnCodeNeeded(!!held && (!pin || publicText(pin) !== publicText(held.identity))); - loaded.current = true; - setReady(true); - } catch { if (!controller.signal.aborted && lifetime.current === version) setFailed(true); } - })(); - return () => { - ++lifetime.current; controller.abort(); - if (task.current) { task.current.controller.abort(); task.current.secret = ""; task.current.code = ""; } - // The parent owns the supplied worker and terminates it on scope exit. - // React's development effect replay must not terminate that live handle. - task.current = null; clearInputs(); - }; - }, [worker, scope.passwordBoxId, account, scope.keyEpoch, recipient]); - - async function flight(action: (current: NonNullable<typeof task.current>, active: () => boolean) => Promise<void>, secret = "", code = "") { - if (task.current || worker.isClosed || document.visibilityState === "hidden" || !loaded.current) { clearInputs(); return; } - const current = { controller: new AbortController(), secret, code }; - secret = ""; code = ""; - const version = lifetime.current; - const active = () => lifetime.current === version && task.current === current && !worker.isClosed && !current.controller.signal.aborted && document.visibilityState !== "hidden"; - task.current = current; setBusy(true); setFailed(false); - const deadline = setTimeout(() => { if (active()) cancel(); }, 60_000); - try { await action(current, active); } - catch (error) { - if (active()) { - if (error instanceof CloudApiError && [401, 403].includes(error.status)) cancel(); - else setFailed(true); - } - } finally { - clearTimeout(deadline); - current.secret = ""; current.code = ""; - if (active()) { task.current = null; setBusy(false); } - } - } - async function unlockIdentity(event: FormEvent) { - event.preventDefault(); - let secret = secretInput.current?.value ?? ""; - let code = ownCode.current?.value ?? ""; - const mismatch = creating && secret !== confirmation.current?.value; - clearInputs(); - if (mismatch) { secret = ""; code = ""; setFailed(true); return; } - const running = flight(async (current, active) => { - // Serialize initial creation across tabs. Pending retries reuse the same - // ciphertext, including when PUT committed but its response was lost. - if (!navigator.locks) throw new CloudApiError(409); - try { await navigator.locks.request(`passshare.identity-create:${account}`, { signal: current.controller.signal }, async () => { - if (!active()) return; - const saved = await getIdentity(current.controller.signal); - if (!active()) return; - const pending = readPendingIdentity(account); - const pin = readOwnPin(account); - // Another item/recovery transport flight must not turn a caller busy - // rejection into destruction of the valid box and its current draft. - if (worker.isBusy) { current.secret = ""; current.code = ""; setFailed(true); return; } - const prepared = await worker.prepareIdentity(JSON.stringify({ - accountContext: JSON.stringify({ accountId: account, identityVersion: 1 }), - ownPin: pin ? JSON.stringify(pin) : "", remoteRecord: saved ? JSON.stringify(saved) : "", - pendingRecord: pending ? JSON.stringify(pending) : "", verificationCode: current.code, - }), current.secret); - current.secret = ""; current.code = ""; - if (!active()) return; - const record = JSON.parse(prepared.record) as IdentityRecord; - const openedPublic = JSON.parse(prepared.publicIdentity) as PublicIdentity; - if (prepared.hasPending) savePendingIdentity(account, record); - savePreparedOwnPin(account, openedPublic); - if (prepared.requiresCreate) { - await createIdentity(record, current.controller.signal); - if (!active()) return; - } - if (prepared.hasPending) { - const readBack = await getIdentity(current.controller.signal); - if (!active()) return; - if (!readBack || publicText(readBack) !== publicText(record)) throw new CloudApiError(409); - clearPendingIdentity(account, record); - } - if (active()) setIdentity(openedPublic); - }); } catch (error) { if (active()) cancel(); throw error; } - }, secret, code); - secret = ""; code = ""; - await running; - } - async function verifyPeer() { - let code = peerCode.current?.value ?? ""; - if (peerCode.current) peerCode.current.value = ""; - setTrusted(null); setComplete(false); - const running = flight(async (current, active) => { - const descriptor = await getMemberIdentity(scope.passwordBoxId, peer, current.controller.signal); - if (!active()) return; - const verified = await worker.verifyPublicIdentity({ accountId: peer, identityVersion: 1 }, JSON.stringify(descriptor), current.code); - current.code = ""; - if (!active()) return; - const pin = JSON.parse(verified) as PublicIdentity; - savePeerPin(account, peer, pin); setTrusted(pin); - }, "", code); - code = ""; - await running; - } - async function share() { - if (!identity || !trusted || complete) return; - await flight(async (current, active) => { - const state = await getBoxState(scope.passwordBoxId, current.controller.signal); - if (!active()) return; - if (state.mode !== "encrypted" || state.keyEpoch !== scope.keyEpoch) { cancel(); return; } - const descriptor = await getMemberIdentity(scope.passwordBoxId, peer, current.controller.signal); - if (!active()) return; - const pin = readPeerPin(account, peer); - if (!pin || publicText(pin) !== publicText(trusted)) throw new CloudApiError(409); - try { await worker.verifyPublicIdentity({ accountId: peer, identityVersion: trusted.identityVersion }, JSON.stringify(descriptor), trusted.keyId); } - catch { if (active()) setTrusted(null); throw new CloudApiError(409); } - if (!active()) return; - let revision = 0; - let selectionMembers; - if (recipient) { - const list = await listVaultMembers(scope.passwordBoxId, current.controller.signal); - if (!active()) return; - selectionMembers = list.map((m) => ({ accountId: m.userId, role: m.role, membershipConfirmed: m.identityConfirmed === true })); - } else { - const snapshot = await getSnapshot(scope.passwordBoxId, current.controller.signal); - if (!active()) return; - if (snapshot.keyEpoch !== scope.keyEpoch || snapshot.ownerAccountId !== account) { cancel(); return; } - const list = await getSnapshotMembers(scope.passwordBoxId, snapshot, current.controller.signal); - if (!active()) return; - revision = snapshot.stateRevision; - selectionMembers = list.map(({ accountId, role, membershipConfirmed }) => ({ accountId, role, membershipConfirmed })); - } - const context = await worker.sharingContext(JSON.stringify({ passwordBoxId: scope.passwordBoxId, callerAccountId: account, - keyEpoch: scope.keyEpoch, sender: recipient ? trusted : identity, recipient: recipient ? identity : trusted, members: selectionMembers })); - if (!active()) return; - if (recipient) { - const envelope = await getSelfGrant(scope.passwordBoxId, context, current.controller.signal); - if (!active()) return; - await worker.receiveShared(envelope, JSON.stringify(trusted), context); - if (active()) onReceive?.(); - } else { - // Retrying after uncertain HTTP output must reuse the same prepared - // envelope. Persist ciphertext before sending; no new HPKE randomness. - const key = `passshare.grant-pending.v1:${account}:${scope.passwordBoxId}:${peer}:${scope.keyEpoch}`; - if (!navigator.locks) throw new CloudApiError(409); - await navigator.locks.request(key, { signal: current.controller.signal }, async () => { - if (!active()) return; - const prepared = localStorage.getItem(key) ?? await worker.wrapForRecipient(JSON.stringify(trusted), context); - if (!active()) return; - if (new TextEncoder().encode(prepared).length > 2048) throw new CloudApiError(409); - localStorage.setItem(key, prepared); - if (localStorage.getItem(key) !== prepared) throw new CloudApiError(409); - const held = JSON.parse(prepared); - if (publicText(held.context) !== publicText(context)) throw new CloudApiError(409); - await putGrant(scope.passwordBoxId, revision, context, held, current.controller.signal); - if (!active()) return; - if (localStorage.getItem(key) !== prepared) throw new CloudApiError(409); - localStorage.removeItem(key); - }); - } - if (active()) setComplete(true); - }); - } - return <section className="cloud-sharing cloud-card" aria-busy={busy || !ready}> - <h2 className="ps-heading-3">{t("sharing.title")}</h2> - <p>{t("sharing.lead")}</p> - {!identity ? <form onSubmit={(event) => void unlockIdentity(event)}> - <label className="ps-label">{t("sharing.identityPassword")}<input ref={secretInput} className="ps-input" type="password" - name="identity-secret" required maxLength={1024} autoComplete="off" spellCheck={false} autoCapitalize="none" disabled={busy || !ready} /></label> - {creating && <label className="ps-label">{t("cloud.confirmation")}<input ref={confirmation} className="ps-input" type="password" - name="identity-confirmation" required maxLength={1024} autoComplete="off" disabled={busy || !ready} /></label>} - {ownCodeNeeded && <label className="ps-label">{t("sharing.savedOwnCode")}<input ref={ownCode} className="ps-input" - name="own-code" required maxLength={256} autoComplete="off" spellCheck={false} disabled={busy || !ready} /></label>} - <p className="cloud-note">{t("sharing.identitySeparate")}</p> - <button className="ps-button cloud-submit" disabled={busy || !ready} type="submit">{t("cloud.openSubmit")}</button> - </form> : <> - <div className="sharing-step"><h3>{t("sharing.ownCode")}</h3><p>{t("sharing.ownHint")}</p> - <div className="sharing-own"><code className="sharing-code">{identity.keyId.match(/.{1,4}/g)?.map((group, index) => <span key={index}>{group}</span>)}</code> - <button className="ps-button ps-button--secondary" type="button" onClick={() => { - void navigator.clipboard.writeText(identity.keyId).catch(() => setFailed(true)); - }}>{t("sharing.copy")}</button></div></div> - <div className="sharing-step"><h3>{t("sharing.peerCode")}</h3> - <label className="ps-label">{t("sharing.peer")}<select className="ps-input" disabled={busy || recipient} value={peer} onChange={(event) => { - setPeer(event.target.value); setTrusted(null); setComplete(false); if (peerCode.current) peerCode.current.value = ""; - }}><option value="">{t("sharing.selectPeer")}</option>{members.filter((m) => m.userId !== account).map((m) => - <option key={m.userId} value={m.userId}>{m.email}</option>)}</select></label> - <label className="ps-label">{t("sharing.peerHint")}<textarea ref={peerCode} className="ps-input" name="peer-code" maxLength={256} - autoComplete="off" spellCheck={false} disabled={busy} onInput={() => { setTrusted(null); setComplete(false); }} /></label> - <div className="sharing-actions"><button className="ps-button" type="button" disabled={busy || !peer} onClick={() => void verifyPeer()}>{t("sharing.verify")}</button> - <button className="ps-button ps-button--secondary" type="button" onClick={cancel}>{t("sharing.cancel")}</button></div> - </div> - <p className="cloud-note">{t(trusted ? "sharing.verified" : "sharing.disabled")}</p> - <button className="ps-button cloud-submit" type="button" disabled={busy || !trusted || complete} onClick={() => void share()}>{t(recipient ? "cloud.openSubmit" : "sharing.start")}</button> - {complete && <p role="status">{t("sharing.complete")}</p>} - </>} - <p className="cloud-note">{t("sharing.irrevocable")}</p> - <p className="cloud-note">{t("sharing.rotationPending")}</p> - {failed && <p className="ps-error" role="alert">{t("sharing.failed")}</p>} - {!identity && <button className="ps-button ps-button--secondary" type="button" onClick={cancel}>{t("sharing.cancel")}</button>} - </section>; -} +import { SharedCloudSharingPanel } from "@shared/cloud/CloudSharingPanel"; +import { webCloudApi } from "../../lib/cloud-api"; +import { webSharingApi } from "../../lib/sharing-api"; +import { listVaultMembers } from "../../lib/auth-api"; +import type { CloudWorker, OwnerContext } from "../../lib/cloud-worker"; +export function CloudSharingPanel(props: { worker: CloudWorker; scope: OwnerContext; recipient?: boolean; onReceive?: () => void; onLock: () => void }) { + return <SharedCloudSharingPanel {...props} api={webCloudApi} sharing={webSharingApi} listVaultMembers={listVaultMembers} />; +} \ No newline at end of file diff --git a/frontend/src/pages/app/EncryptedItemsPage.tsx b/frontend/src/pages/app/EncryptedItemsPage.tsx index b674a689..0872adcb 100644 --- a/frontend/src/pages/app/EncryptedItemsPage.tsx +++ b/frontend/src/pages/app/EncryptedItemsPage.tsx @@ -1,552 +1,8 @@ -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; -import type { Item } from "@passshare/connect"; -import { ItemForm } from "@shared/ui/ItemForm.js"; -import { categoryLabel, emptyFormValues, formValuesFromItem, ITEM_CATEGORIES, - type ConnectItem, type ItemCategory, type ItemFormValues } from "@shared/types/item.js"; -import { useI18n } from "@shared/i18n/I18nProvider"; -import type { MessageKey } from "@shared/i18n/index"; +import { EncryptedItems } from "@shared/cloud/EncryptedItems"; import { useAuthSession } from "../../hooks/useAuthSession"; -import { useItemLabels } from "../../hooks/useItemLabels"; -import { connectItemFromApi } from "../../lib/connect-items"; -import { CloudApiError, deleteEncryptedItem, getBoxState, getEncryptedItem, - listEncryptedItems, putEncryptedItem, readItemEnvelope, validateItemContext, - getAttachmentState, getAttachmentItemSnapshot, getEncryptedAttachment, writeEncryptedAttachment, type AttachmentWrite } from "../../lib/cloud-api"; -import type { AttachmentContext, CloudWorker, ItemContext, OwnerContext } from "../../lib/cloud-worker"; -import "./encrypted-items.css"; - -type Row = { context: ItemContext; originalJSON: string; display: ConnectItem }; -type FileDraft = { key: number; context: AttachmentContext | null; name: string; size: number; file: File | null; removed: boolean; savedName: string; savedSize: number }; -type Draft = { original: Row | null; values: ItemFormValues; files: FileDraft[]; attachmentReady: boolean }; -type Prepared = { kind: "put"; context: ItemContext; expectedVersion: number; envelopeJSON: string } | - { kind: "delete"; context: ItemContext } | - { kind: "attachment"; write: AttachmentWrite; fileKey: number }; -type Runtime = { alive: boolean; busy: boolean; ready: boolean; rows: Row[]; draft: Draft | null; - prepared: Prepared | null; deleting: Row | null; outcome: "none" | "unknown" | "conflict" | "rejected"; - error: MessageKey | null; query: string; category: ItemCategory; revealed: boolean; - controllers: Set<AbortController>; buffers: Set<Uint8Array>; urls: Set<string>; fileKey: number }; -function runtime(): Runtime { - return { alive: true, busy: false, ready: false, rows: [], draft: null, prepared: null, deleting: null, - outcome: "none", error: null, query: "", category: "LOGIN", revealed: false, controllers: new Set(), - buffers: new Set(), urls: new Set(), fileKey: 0 }; -} -function clearFiles(draft: Draft | null) { - if (!draft) return; - for (const file of draft.files) { file.file = null; file.name = ""; file.savedName = ""; } - draft.files = []; -} -function clear(runtime: Runtime) { - runtime.alive = false; - for (const controller of runtime.controllers) controller.abort(); - runtime.controllers.clear(); - for (const bytes of runtime.buffers) if (bytes.byteLength) bytes.fill(0); - runtime.buffers.clear(); - for (const url of runtime.urls) URL.revokeObjectURL(url); - runtime.urls.clear(); - for (const row of runtime.rows) { row.originalJSON = ""; row.display = { ...row.display, title: "", tags: [], - username: null, password: null, body: null, notes: null, url: null }; } - if (runtime.draft) runtime.draft.values = emptyFormValues(); - clearFiles(runtime.draft); - runtime.rows = []; runtime.draft = null; runtime.prepared = null; runtime.deleting = null; - runtime.query = ""; runtime.revealed = false; runtime.busy = false; runtime.ready = false; runtime.error = null; -} - -// The parent supplies an already-unlocked worker and its held, trusted owner -// scope. No Connect credentials, keys, or password inputs enter this component. -export function EncryptedItemsPage({ worker, scope, onLock }: { - worker: CloudWorker; scope: OwnerContext; onLock: () => void; -}) { +import { webCloudApi } from "../../lib/cloud-api"; +import type { CloudWorker, OwnerContext } from "../../lib/cloud-worker"; +export function EncryptedItemsPage(props: { worker: CloudWorker; scope: OwnerContext; onLock: () => void }) { const { user } = useAuthSession(); - const { t } = useI18n(); - const labels = useItemLabels(); - const { passwordBoxId, ownerAccountId, keyEpoch } = scope; - const session = useMemo(runtime, [worker, passwordBoxId, ownerAccountId, keyEpoch, user?.id]); - const [, redraw] = useState(0); - const current = useRef({ session, userId: user?.id }); - current.current = { session, userId: user?.id }; - const lockRef = useRef(onLock); - lockRef.current = onLock; - const formHost = useRef<HTMLDivElement>(null); - const fileInput = useRef<HTMLInputElement>(null); - const fileTarget = useRef<number | null>(null); - const returnFocus = useRef<HTMLElement | null>(null); - const restoreFocus = useRef(false); - const bindFileInput = useCallback((input: HTMLInputElement | null) => { - const previous = fileInput.current; - if (previous && previous !== input) { previous.value = ""; previous.oncancel = null; fileTarget.current = null; } - fileInput.current = input; - if (input) input.oncancel = () => { input.value = ""; fileTarget.current = null; }; - }, []); - const changed = () => redraw((revision) => revision + 1); - const active = (controller?: AbortController) => session.alive && !worker.isClosed && current.current.session === session && - current.current.userId === ownerAccountId && !controller?.signal.aborted && document.visibilityState !== "hidden"; - useLayoutEffect(() => { - if (restoreFocus.current && active() && !session.draft && !session.busy) { - restoreFocus.current = false; returnFocus.current?.focus(); - } - }, [session.draft, session.busy]); - function clearInput() { if (fileInput.current) fileInput.current.value = ""; fileTarget.current = null; } - function revoke() { clearInput(); clear(session); worker.dispose(); changed(); lockRef.current(); } - - function rowFromJSON(originalJSON: string, context: ItemContext): Row { - // This is a display adapter only. The original worker-validated full JSON is - // kept verbatim and passed back to Rust for lossless edits. - const item = JSON.parse(originalJSON) as Item; - const display = connectItemFromApi(item); - if (display.id !== context.itemId || item.version !== context.itemVersion || - !ITEM_CATEGORIES.includes(display.category)) throw new CloudApiError(502); - return { context, originalJSON, display }; - } - async function fail(error: unknown, controller: AbortController) { - if (!active(controller)) return; - if (error instanceof CloudApiError && (error.status === 401 || error.status === 403 || error.code === "PS056")) { - revoke(); return; - } - if (error instanceof CloudApiError && error.status === 409) { - // Check only public mode/epoch; never replace the draft or its original - // document with a refetched item after a conflict. - try { - const state = await getBoxState(passwordBoxId, controller.signal); - if (!active(controller)) return; - if (state.mode !== "encrypted" || state.keyEpoch !== keyEpoch) { revoke(); return; } - } catch { if (active(controller)) revoke(); return; } - session.outcome = "conflict"; - session.error = "encryptedItems.conflict"; - } else { - // authFetch's CSRF step can hide an HTTP status behind a generic error. - // Verify only public scope when available; a network failure still keeps - // the prepared operation so it can be reconciled without new encryption. - if (error instanceof CloudApiError && error.status === 0) { - try { - const state = await getBoxState(passwordBoxId, controller.signal); - if (!active(controller)) return; - if (state.mode !== "encrypted" || state.keyEpoch !== keyEpoch) { revoke(); return; } - } catch (scopeError) { - if (!active(controller)) return; - if (scopeError instanceof CloudApiError && (scopeError.status === 401 || scopeError.status === 403)) { revoke(); return; } - } - } - const rejected = error instanceof CloudApiError && error.status >= 400 && error.status < 500; - // A rejection of this replay (including 429) says nothing about a prior - // request whose reply was lost. Retain that uncertainty and the exact - // prepared payload until success, a real conflict, or a scope lock. - const uncertain = session.outcome === "unknown" || !rejected; - session.outcome = session.prepared ? (uncertain ? "unknown" : "rejected") : "none"; - session.error = session.outcome === "unknown" ? "encryptedItems.unknown" : "encryptedItems.failed"; - } - changed(); - } - async function oneFlight(action: (controller: AbortController) => Promise<void>) { - if (!active() || session.busy) return; - session.busy = true; - const controller = new AbortController(); - session.controllers.add(controller); - changed(); - try { await action(controller); } - catch (error) { await fail(error, controller); } - finally { - session.controllers.delete(controller); - if (active(controller)) { session.busy = false; changed(); } - } - } - async function load(controller: AbortController) { - if (!active(controller) || session.prepared || session.draft || session.deleting) return; - session.ready = false; session.error = null; session.outcome = "none"; - session.rows = []; - changed(); - let after: string | null = null; - const cursors = new Set<string>(); - const ids = new Set<string>(); - const contexts: ItemContext[] = []; - do { - const page = await listEncryptedItems(passwordBoxId, keyEpoch, after, controller.signal); - if (!active(controller)) return; - for (const metadata of page.items) { - if (ids.has(metadata.itemId)) throw new CloudApiError(502); - ids.add(metadata.itemId); - contexts.push({ passwordBoxId, itemId: metadata.itemId, itemVersion: metadata.itemVersion, keyEpoch }); - } - after = page.nextCursor; - if (after !== null) { - if (cursors.has(after)) throw new CloudApiError(502); - cursors.add(after); - } - } while (after !== null); - for (const context of contexts) { - const envelopeJSON = await getEncryptedItem(context, controller.signal); - if (!active(controller)) return; - const originalJSON = await worker.decrypt(envelopeJSON, context); - if (!active(controller)) return; - session.rows.push(rowFromJSON(originalJSON, context)); - } - if (active(controller)) { session.ready = true; changed(); } - } - - useLayoutEffect(() => { - // Cleanup runs before paint for changed account, box, epoch, or worker. - session.alive = true; - const stop = () => revoke(); - const background = () => { if (document.visibilityState === "hidden") stop(); }; - window.addEventListener("passshare:cloud-lock", stop); - window.addEventListener("pagehide", stop); - window.addEventListener("popstate", stop); - document.addEventListener("visibilitychange", background); - void worker.closed.then(() => { - if (session.alive && current.current.session === session && current.current.userId === ownerAccountId) stop(); - }); - if (active()) void oneFlight(load); - return () => { - clearInput(); - clear(session); - window.removeEventListener("passshare:cloud-lock", stop); - window.removeEventListener("pagehide", stop); - window.removeEventListener("popstate", stop); - document.removeEventListener("visibilitychange", background); - }; - }, [session]); - - function installRow(row: Row) { - session.rows = [...session.rows.filter((prior) => prior.context.itemId !== row.context.itemId), row] - .sort((left, right) => left.context.itemId.localeCompare(right.context.itemId)); - } - function finishDraft() { - clearInput(); clearFiles(session.draft); session.draft = null; - session.revealed = false; - restoreFocus.current = true; - } - function references(row: Row): AttachmentContext[] { - // Display/transport projection only. Rust authenticates and validates these - // references again against the independent item context and held revision. - const item = JSON.parse(row.originalJSON); - return (item.attachments ?? []).map((reference: { attachmentId: string; attachmentVersion: number; keyEpoch: number }) => - ({ passwordBoxId, itemId: row.context.itemId, attachmentId: reference.attachmentId, - attachmentVersion: reference.attachmentVersion, keyEpoch: reference.keyEpoch })); - } - async function attachmentSnapshot(row: Row | null, controller: AbortController) { - const snapshot = await getAttachmentState(passwordBoxId, controller.signal); - if (!active(controller)) return null; - if (snapshot.keyEpoch !== keyEpoch) throw new CloudApiError(409, "PS056"); - const envelope = row ? await getAttachmentItemSnapshot(row.context, snapshot.stateRevision, controller.signal) : null; - if (!active(controller)) return null; - return { revision: snapshot.stateRevision, envelope }; - } - async function readFile(row: Row, context: AttachmentContext, controller: AbortController) { - const snapshot = await attachmentSnapshot(row, controller); - if (!snapshot || !snapshot.envelope) return null; - const envelope = await getEncryptedAttachment(context, snapshot.revision, controller.signal); - if (!active(controller)) return null; - const plaintext = await worker.decryptAttachment(envelope, context, { itemEnvelope: snapshot.envelope, - expectedItemContext: row.context, itemSnapshotRevision: snapshot.revision, attachmentSnapshotRevision: snapshot.revision }); - if (!active(controller)) { worker.releaseAttachment(plaintext); return null; } - return plaintext; - } - async function loadFiles(draft: Draft, controller: AbortController) { - // Rejected attachment access must not discard the current item form. - try { - const snapshot = await attachmentSnapshot(draft.original, controller); - if (!snapshot || !active(controller) || session.draft !== draft) return; - if (draft.original) for (const context of references(draft.original)) { - const plaintext = await readFile(draft.original, context, controller); - if (!plaintext) return; - try { - if (!active(controller) || session.draft !== draft) return; - draft.files.push({ key: ++session.fileKey, context, name: plaintext.name, size: plaintext.bytes.byteLength, - savedName: plaintext.name, savedSize: plaintext.bytes.byteLength, file: null, removed: false }); - } finally { worker.releaseAttachment(plaintext); } - } - if (active(controller) && session.draft === draft) draft.attachmentReady = true; - } catch (error) { - clearFiles(draft); - if (error instanceof CloudApiError && error.status === 403 && active(controller)) session.error = "attachments.unavailable"; - else throw error; - } - } - - async function sendPrepared(controller: AbortController) { - const prepared = session.prepared; - if (!prepared || !active(controller) || session.outcome === "conflict" || session.outcome === "rejected") return; - if (prepared.kind === "delete") { - await deleteEncryptedItem(prepared.context, controller.signal); - if (!active(controller)) return; - session.rows = session.rows.filter((row) => row.context.itemId !== prepared.context.itemId); - session.deleting = null; - } else if (prepared.kind === "attachment") { - await writeEncryptedAttachment(prepared.write, controller.signal); - if (!active(controller)) return; - const originalJSON = await worker.decrypt(prepared.write.itemEnvelope, prepared.write.itemContext); - if (!active(controller)) return; - const draft = session.draft; - const row = rowFromJSON(originalJSON, prepared.write.itemContext); - installRow(row); - if (draft) { - draft.original = row; - const file = draft.files.find((file) => file.key === prepared.fileKey); - if (file) { - if (prepared.write.envelope === null) { - file.file = null; file.name = ""; file.savedName = ""; - draft.files = draft.files.filter((entry) => entry !== file); - } - else { file.context = prepared.write.context; file.file = null; file.removed = false; file.savedName = file.name; file.savedSize = file.size; } - } - } - } else { - const stored = await putEncryptedItem(prepared.context, prepared.expectedVersion, prepared.envelopeJSON, controller.signal); - if (!active(controller)) return; - const originalJSON = await worker.decrypt(stored, prepared.context); - if (!active(controller)) return; - const row = rowFromJSON(originalJSON, prepared.context); - installRow(row); - if (session.draft) session.draft.original = row; - } - session.prepared = null; session.outcome = "none"; session.error = null; session.revealed = false; - changed(); - } - async function saveRemaining(controller: AbortController) { - await sendPrepared(controller); - if (!active(controller) || session.prepared || !session.draft) return; - const draft = session.draft; - while (active(controller) && session.draft === draft) { - const file = draft.files.find((file) => file.file !== null || file.removed); - if (!file) { finishDraft(); changed(); return; } - const original = draft.original; - if (!original) throw new CloudApiError(502); - const snapshot = await attachmentSnapshot(original, controller); - if (!snapshot || !active(controller)) return; - const context = { ...original.context, itemVersion: original.context.itemVersion + 1 }; - const action = file.removed ? "remove" : file.context ? "replace" : "add"; - const itemEnvelope = await worker.editAttachmentItem(original.originalJSON, JSON.stringify({ form: draft.values, action, - ...(file.context ? { attachmentId: file.context.attachmentId } : {}) }), context); - if (!active(controller)) return; - const originalJSON = await worker.decrypt(itemEnvelope, context); - if (!active(controller)) return; - const nextRow = rowFromJSON(originalJSON, context); - const refs = references(nextRow); - const attachmentContext = action === "remove" ? file.context! : action === "add" ? refs[refs.length - 1] : - refs.find((ref) => ref.attachmentId === file.context!.attachmentId)!; - let envelope: string | null = null; - if (!file.removed) { - const bytes = new Uint8Array(await file.file!.arrayBuffer()); - session.buffers.add(bytes); - try { - if (!active(controller) || session.draft !== draft) return; - envelope = await worker.encryptAttachment(file.name, bytes, attachmentContext); - if (!active(controller)) return; - } finally { if (bytes.byteLength) bytes.fill(0); session.buffers.delete(bytes); } - } - session.prepared = { kind: "attachment", fileKey: file.key, write: { context: attachmentContext, - itemContext: context, revision: snapshot.revision, itemEnvelope, expectedVersion: file.context?.attachmentVersion ?? 0, envelope } }; - await sendPrepared(controller); - if (session.prepared || !active(controller)) return; - } - } - function save(values: ItemFormValues) { - if (!active() || session.busy || !session.ready || !session.draft || session.prepared || session.outcome !== "none") return; - session.draft.values = values; - void oneFlight(async (controller) => { - if (!session.prepared) { - const original = session.draft?.original; - let context: ItemContext; - let envelopeJSON: string; - const completeFormValuesJSON = JSON.stringify(values); - if (original) { - if (original.context.itemVersion >= Number.MAX_SAFE_INTEGER) throw new CloudApiError(502); - context = { ...original.context, itemVersion: original.context.itemVersion + 1 }; - envelopeJSON = await worker.editItem(original.originalJSON, completeFormValuesJSON, context); - } else { - envelopeJSON = await worker.createItem(completeFormValuesJSON); - if (!active(controller)) return; - const envelope = readItemEnvelope(envelopeJSON); - // The creation ID is generated by Rust, never by a GET response or UI. - context = { passwordBoxId, itemId: envelope.context.itemId, itemVersion: 1, keyEpoch }; - } - if (!active(controller)) return; - validateItemContext(context); - readItemEnvelope(envelopeJSON, context); - session.prepared = { kind: "put", context, expectedVersion: context.itemVersion - 1, envelopeJSON }; - } - await saveRemaining(controller); - }); - } - function cancel() { - if (!active()) return; - if (session.busy) { revoke(); return; } - finishDraft(); session.deleting = null; - if (session.outcome === "unknown") { - // Discard plaintext draft explicitly, but reconcile the outstanding exact - // operation before allowing another payload or another creation ID. - changed(); return; - } - const conflict = session.outcome === "conflict" || session.outcome === "rejected"; - session.prepared = null; session.outcome = "none"; session.error = null; - changed(); - if (conflict) void oneFlight(load); - } - function startDraft(row: Row | null) { - if (!active() || session.busy || !session.ready || session.prepared || session.draft || session.deleting) return; - session.error = null; session.revealed = false; - returnFocus.current = document.activeElement as HTMLElement; - const draft: Draft = { original: row, values: row ? formValuesFromItem(row.display) : emptyFormValues(session.category), files: [], attachmentReady: false }; - session.draft = draft; - changed(); - void oneFlight(async (controller) => { await loadFiles(draft, controller); if (active(controller)) formHost.current?.querySelector<HTMLInputElement>("input")?.focus(); }); - } - function remove(row: Row) { - if (!active() || session.busy || !session.ready || session.draft || session.prepared || session.deleting) return; - session.deleting = row; session.error = null; changed(); - } - function confirmRemove() { - if (!session.deleting || session.outcome !== "none") return; - void oneFlight(async (controller) => { - session.prepared ??= { kind: "delete", context: { ...session.deleting!.context } }; - await sendPrepared(controller); - }); - } - function chooseFile(target: number | null = null) { - if (!active() || session.busy || session.prepared || session.outcome !== "none" || !session.draft?.attachmentReady) return; - clearInput(); fileTarget.current = target; - if (fileInput.current) { fileInput.current.multiple = target === null; fileInput.current.click(); } - } - function selectedFiles(input: HTMLInputElement) { - const selected = Array.from(input.files ?? []); - const target = fileTarget.current; - clearInput(); - const draft = session.draft; - if (!active() || session.busy || session.prepared || session.outcome !== "none" || !draft?.attachmentReady) return; - if (selected.some((file) => file.size > 1048576)) { session.error = "attachments.tooLarge"; changed(); return; } - if (target !== null) { - const file = draft.files.find((file) => file.key === target); - if (file && selected.length === 1) { file.file = selected[0]; file.name = selected[0].name; file.size = selected[0].size; file.removed = false; } - } else for (const file of selected) draft.files.push({ key: ++session.fileKey, context: null, name: file.name, size: file.size, - savedName: "", savedSize: 0, file, removed: false }); - session.error = null; changed(); - formHost.current?.querySelector<HTMLElement>(".encrypted-attachments__heading")?.focus(); - } - function discardFile(file: FileDraft) { - const draft = session.draft; - if (!active() || session.busy || session.prepared || session.outcome !== "none" || !draft?.files.includes(file)) return; - if (!file.context) { file.file = null; file.name = ""; draft.files = draft.files.filter((entry) => entry !== file); } - else { file.removed = !file.removed; } - changed(); formHost.current?.querySelector<HTMLElement>(".encrypted-attachments__heading")?.focus(); - } - function undoReplace(file: FileDraft) { - if (!active() || session.busy || session.prepared || session.outcome !== "none" || !session.draft?.files.includes(file)) return; - file.file = null; file.name = file.savedName; file.size = file.savedSize; - changed(); formHost.current?.querySelector<HTMLElement>(".encrypted-attachments__heading")?.focus(); - } - function download(file: FileDraft) { - const draft = session.draft; - if (!draft?.original || !draft.files.includes(file) || !file.context || file.removed || file.file) return; - const row = draft.original, context = file.context; - void oneFlight(async (controller) => { - const plaintext = await readFile(row, context, controller); - if (!plaintext) return; - try { - if (!active(controller) || session.draft !== draft) return; - const blob = new Blob([plaintext.bytes.buffer as ArrayBuffer], { type: "application/octet-stream" }); - const url = URL.createObjectURL(blob); - session.urls.add(url); - const anchor = document.createElement("a"); - anchor.href = url; anchor.download = plaintext.name; document.body.append(anchor); - try { anchor.click(); } finally { anchor.remove(); URL.revokeObjectURL(url); session.urls.delete(url); } - } finally { worker.releaseAttachment(plaintext); } - }); - } - // Ref scope guard applies during render, before layout effects can run. - if (!active()) return null; - const blocked = session.busy || !session.ready || !!session.draft || !!session.deleting || !!session.prepared; - const query = session.query.trim().toLocaleLowerCase(); - const rows = session.ready ? session.rows.filter(({ display }) => display.category === session.category && - (!query || [display.title, display.username, display.url, ...display.tags].some((value) => value?.toLocaleLowerCase().includes(query)))) : []; - return <div className="encrypted-items" aria-busy={session.busy}> - <p className="app-success" role="status">{t("cloud.opened")}</p> - <input type="search" className="ps-input" aria-label={labels.searchPlaceholder} placeholder={labels.searchPlaceholder} - value={session.query} onChange={(event) => { if (active()) { session.query = event.target.value; changed(); } }} /> - <div className="encrypted-items__toolbar"> - <div className="encrypted-items__categories" role="group" aria-label={t("encryptedItems.categories")}> - {ITEM_CATEGORIES.map((category) => <button key={category} type="button" - className={`ps-button ${category === session.category ? "ps-button--primary" : "ps-button--secondary"}`} - aria-pressed={category === session.category} onClick={() => { if (active()) { session.category = category; changed(); } }}> - {categoryLabel(category, labels)}</button>)} - </div> - <button type="button" className="ps-button ps-button--primary" disabled={blocked} onClick={() => startDraft(null)}>{labels.submitAdd}</button> - <button type="button" className="ps-button ps-button--secondary" disabled={session.busy || !!session.draft || !!session.deleting || !!session.prepared} - onClick={() => void oneFlight(load)}>{t("app.item.reload")}</button> - </div> - {session.busy && <p role="status">{t("cloud.working")}</p>} - {session.outcome === "unknown" && <button type="button" className="ps-button ps-button--secondary" disabled={session.busy} - onClick={() => void oneFlight(session.draft ? saveRemaining : sendPrepared)}>{t("encryptedItems.retrySame")}</button>} - {session.prepared && !session.draft && !session.deleting && - (session.outcome === "conflict" || session.outcome === "rejected") && - <button type="button" className="ps-button ps-button--secondary" disabled={session.busy} - onClick={cancel}>{labels.cancelEdit} / {t("app.item.reload")}</button>} - {session.deleting && <section className="ps-card" aria-label={labels.delete}> - <h3>{session.deleting.display.title}</h3><p>{labels.confirmDelete}</p> - <div className="ps-actions"><button type="button" className="ps-button ps-button--secondary" - disabled={session.busy || session.outcome !== "none"} onClick={confirmRemove}>{labels.delete}</button> - <button type="button" className="ps-button ps-button--secondary" disabled={session.busy} onClick={cancel}>{labels.cancelEdit}</button></div> - </section>} - {session.draft ? <div className="encrypted-items__form" ref={formHost}> - <ItemForm mode={session.draft.original ? "edit" : "create"} values={session.draft.values} - labels={{ ...labels, submitAdd: labels.submitSave }} disabled={session.busy || !!session.prepared || session.outcome !== "none"} - onChange={(values) => { if (active() && !session.busy && !session.prepared && session.draft) { - if (values.category !== session.draft.values.category) { - session.revealed = false; - const input = formHost.current?.querySelector<HTMLInputElement>('input[autocomplete="new-password"]'); - if (input) input.type = "password"; - } - session.draft.values = values; changed(); - } }} - onSubmit={save} onCancel={cancel} /> - <section className="ps-card encrypted-attachments" aria-labelledby="encrypted-attachments-heading" - aria-describedby="encrypted-attachments-limit encrypted-attachments-security"> - <h3 id="encrypted-attachments-heading" className="ps-heading-3 encrypted-attachments__heading" tabIndex={-1}>{t("attachments.heading")}</h3> - <ul>{session.draft.files.map((file) => <li key={file.key} className="encrypted-attachments__row"> - <div className="encrypted-attachments__name"><span>{file.name}</span><small>{file.size} B</small> - {(file.file || file.removed) && <span role="status">{t(file.removed ? "attachments.pendingDelete" : "attachments.pending")}</span>}</div> - <div className="encrypted-attachments__actions" role="group" aria-label={file.name}> - {file.context && !file.file && !file.removed && <button type="button" className="ps-button ps-button--secondary" disabled={session.busy || !!session.prepared} - onClick={() => download(file)}>{t("attachments.download")}</button>} - {!file.removed && <button type="button" className="ps-button ps-button--secondary" disabled={session.busy || !!session.prepared || session.outcome !== "none"} - onClick={() => chooseFile(file.key)}>{t("attachments.replace")}</button>} - {file.context && file.file && <button type="button" className="ps-button ps-button--secondary" disabled={session.busy || !!session.prepared || session.outcome !== "none"} - onClick={() => undoReplace(file)}>{t("attachments.undoReplace")}</button>} - <button type="button" className="ps-button ps-button--secondary" disabled={session.busy || !!session.prepared || session.outcome !== "none"} - onClick={() => discardFile(file)}>{t(file.context ? file.removed ? "attachments.undoDelete" : "attachments.delete" : "attachments.cancel")}</button> - </div> - </li>)}</ul> - <input ref={bindFileInput} type="file" hidden multiple onChange={(event) => selectedFiles(event.currentTarget)} /> - <button type="button" className="ps-button ps-button--secondary" disabled={session.busy || !!session.prepared || !session.draft.attachmentReady || session.outcome !== "none"} - onClick={() => chooseFile()}>{t("attachments.add")}</button> - <p id="encrypted-attachments-limit">{t("attachments.limit")}</p> - <p id="encrypted-attachments-security">{t("attachments.encrypted")}</p> - </section> - {(session.draft.values.category === "LOGIN" || session.draft.values.category === "PASSWORD") && - <button type="button" className="ps-button ps-button--secondary encrypted-items__eye" disabled={session.busy} - aria-pressed={session.revealed} aria-label={t(session.revealed ? "encryptedItems.hidePassword" : "encryptedItems.showPassword")} - onClick={() => { - const input = formHost.current?.querySelector<HTMLInputElement>('input[autocomplete="new-password"]'); - if (input && active()) { session.revealed = !session.revealed; input.type = session.revealed ? "text" : "password"; changed(); } - }}>{t(session.revealed ? "encryptedItems.hidePassword" : "encryptedItems.showPassword")}</button>} - <div className="encrypted-items__footer"> - <button type="button" className="ps-button ps-button--primary" disabled={session.busy || !!session.prepared || session.outcome !== "none"} - onClick={() => { if (active()) formHost.current?.querySelector("form")?.requestSubmit(); }}>{labels.submitSave}</button> - <button type="button" className="ps-button ps-button--secondary" onClick={cancel}>{labels.cancelEdit}</button> - </div> - </div> : session.ready && <div className="encrypted-items__list"> - <div className="encrypted-items__row encrypted-items__head" aria-hidden="true"> - <span>{labels.fieldTitle}</span><span>{labels.fieldUsername}</span><span>{labels.fieldPassword}</span><span>{t("encryptedItems.actions")}</span> - </div> - <ul>{rows.map((row) => <li className="encrypted-items__row" key={row.context.itemId}> - <div><strong>{row.display.title}</strong>{row.display.url && <span className="encrypted-items__url">{row.display.url}</span>}</div> - <span><span className="encrypted-items__mobile-label">{labels.fieldUsername}: </span>{row.display.username || labels.notSet}</span> - <span><span className="encrypted-items__mobile-label">{labels.fieldPassword}: </span>{row.display.password ? "••••••••" : labels.notSet}</span> - <div className="encrypted-items__actions"><button type="button" className="ps-button ps-button--secondary" disabled={blocked} - onClick={() => startDraft(row)} aria-label={`${labels.edit}: ${row.display.title}`}>{labels.edit}</button> - <button type="button" className="ps-button ps-button--secondary" disabled={blocked} - onClick={() => remove(row)} aria-label={`${labels.delete}: ${row.display.title}`}>{labels.delete}</button></div> - </li>)}</ul> - {rows.length === 0 && <p>{labels.emptyList}</p>} - </div>} - {session.error && <p className="ps-error" role="alert">{t(session.error)}</p>} - <p className="cloud-note">{t("encryptedItems.deviceOnly")}</p> - </div>; -} + return <EncryptedItems {...props} userId={user?.id ?? ""} api={webCloudApi} />; +} \ No newline at end of file diff --git a/frontend/src/pages/app/cloud-sharing.css b/frontend/src/pages/app/cloud-sharing.css index dbc32934..8164132a 100644 --- a/frontend/src/pages/app/cloud-sharing.css +++ b/frontend/src/pages/app/cloud-sharing.css @@ -1,10 +1 @@ -.cloud-sharing { margin-block: 1rem; min-width: 0; } -.sharing-step { border: 1px solid #28596b; border-radius: .5rem; padding: .8rem; display: grid; gap: .6rem; min-width: 0; } -.sharing-step h3 { margin: 0; font-size: 1rem; } -.sharing-own { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .8rem; min-width: 0; } -.sharing-code { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .3rem .8rem; line-height: 1.8; max-width: 100%; } -.sharing-code span { min-width: 0; } -.sharing-actions { display: flex; flex-wrap: wrap; gap: .6rem; } -.sharing-actions > button { flex: 1 1 10rem; } -.cloud-sharing .ps-input { min-width: 0; max-width: 100%; } -@media (max-width: 400px) { .cloud-sharing { padding: .6rem; } .sharing-step { padding: .5rem; } .sharing-own > button { width: 100%; } } +@import "../../../../shared/cloud/cloud-sharing.css"; diff --git a/frontend/src/pages/app/encrypted-items.css b/frontend/src/pages/app/encrypted-items.css index 0c2b08a0..e318662f 100644 --- a/frontend/src/pages/app/encrypted-items.css +++ b/frontend/src/pages/app/encrypted-items.css @@ -1,42 +1 @@ -.encrypted-items { display: grid; gap: var(--ps-space-4, 1rem); min-width: 0; } -.cloud-page .encrypted-items-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--ps-space-3, .75rem); min-width: 0; } -.cloud-page .encrypted-items-header h2 { min-width: 0; overflow-wrap: anywhere; } -.cloud-page .encrypted-items-header .cloud-submit { width: auto; max-width: 100%; margin: 0; } -.encrypted-items *, .encrypted-items *::before, .encrypted-items *::after { box-sizing: border-box; } -.encrypted-items__toolbar, .encrypted-items__categories, .encrypted-items__actions { display: flex; flex-wrap: wrap; gap: var(--ps-space-2, .5rem); min-width: 0; } -.encrypted-items__categories { flex: 1 1 100%; } -.encrypted-items .ps-button { max-width: 100%; white-space: normal; overflow-wrap: anywhere; height: auto; min-height: 44px; } -.encrypted-items__list { border: 1px solid var(--ps-border); border-radius: var(--ps-radius-md); overflow: hidden; min-width: 0; } -.encrypted-items__list ul { list-style: none; margin: 0; padding: 0; } -.encrypted-items__row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, .7fr) minmax(0, 1fr); gap: var(--ps-space-3, .75rem); align-items: center; padding: var(--ps-space-3, .75rem); border-bottom: 1px solid var(--ps-border); } -.encrypted-items__row > * { min-width: 0; overflow-wrap: anywhere; } -.encrypted-items__head { background: var(--ps-bg-elevated); font-size: var(--ps-text-sm); } -.encrypted-items__url { display: block; font-size: var(--ps-text-sm); color: var(--ps-text-muted); } -.encrypted-items__mobile-label { display: none; } -.encrypted-items__form { position: relative; display: grid; gap: var(--ps-space-3, .75rem); min-width: 0; } -.encrypted-items__form .ps-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--ps-space-4, 1rem); } -.encrypted-items__form .ps-label--full { grid-column: 1 / -1; } -.encrypted-items__form .ps-label, .encrypted-items__form .ps-input { min-width: 0; max-width: 100%; } -/* Keep ItemForm's submission/validation (including Enter) and place both modes' - Save/Cancel together. Cancellation must remain available for frozen drafts. */ -.encrypted-items__form .ps-actions { display: none; } -.encrypted-items__footer { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--ps-space-3, .75rem); } -.encrypted-items__eye { justify-self: end; } -.encrypted-items__form .ps-card { min-width: 0; } -.encrypted-items__list > p { padding: var(--ps-space-3, .75rem); } -.encrypted-attachments { min-width: 0; line-break: strict; word-break: normal; } -.encrypted-attachments ul { list-style: none; padding: 0; margin: 0 0 .75rem; } -.encrypted-attachments__row { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; border: 1px solid var(--ps-border); border-radius: var(--ps-radius-md); padding: .75rem; } -.encrypted-attachments__name { display: grid; gap: .25rem; flex: 1 1 12rem; min-width: 0; overflow-wrap: anywhere; } -.encrypted-attachments__name small { color: var(--ps-text-muted); } -.encrypted-attachments__actions { display: flex; flex-wrap: wrap; gap: .5rem; min-width: 0; } -.encrypted-attachments__heading:focus { outline: 2px solid var(--ps-border); outline-offset: 4px; } -.encrypted-attachments p { overflow-wrap: anywhere; } -@media (max-width: 680px) { - .encrypted-items__head { display: none; } - .encrypted-items__row { grid-template-columns: minmax(0, 1fr); } - .encrypted-items__mobile-label { display: inline; } - .encrypted-items__form .ps-grid, .encrypted-items__footer { grid-template-columns: minmax(0, 1fr); } - .encrypted-items__form .ps-card { padding: var(--ps-space-3, .75rem); } - .encrypted-items__actions .ps-button { flex: 1 1 0; } -} +@import "../../../../shared/cloud/encrypted-items.css"; diff --git a/frontend/tests/attachments-ui.test.mjs b/frontend/tests/attachments-ui.test.mjs index 872cc4ff..fa7ba711 100644 --- a/frontend/tests/attachments-ui.test.mjs +++ b/frontend/tests/attachments-ui.test.mjs @@ -29,6 +29,10 @@ function moduleAt(path, imports, globals = {}) { Uint8Array, ArrayBuffer, Blob, URL, TextEncoder, TextDecoder, AbortController, Number, Object, ...globals }); return exports; } +function apiAt(imports, globals) { + const module = moduleAt('../shared/cloud/cloud-api.ts', {}, globals); + return module.createCloudApi(imports['./auth-api'].authFetch); +} function target() { const listeners = new Map(); return { addEventListener(type, fn) { const set = listeners.get(type) ?? new Set(); set.add(fn); listeners.set(type, set); }, @@ -91,16 +95,16 @@ function host({ empty = false, attachmentFailure = null, itemFailure = null, hol '@shared/types/item.js': { emptyFormValues: () => ({ ...values, title: '', password: '' }), formValuesFromItem: () => ({ ...values }), ITEM_CATEGORIES: ['LOGIN'], categoryLabel: () => 'LOGIN' }, '@shared/i18n/I18nProvider': { useI18n: () => ({ t: (key) => key }) }, '../../hooks/useAuthSession': { useAuthSession: () => ({ user: { id: userId } }) }, - '../../hooks/useItemLabels': { useItemLabels: () => labels }, '../../lib/connect-items': { connectItemFromApi: (item) => ({ ...item, tags: [] }) }, - '../../lib/cloud-api': api, './encrypted-items.css': {}, + './useItemLabels': { useItemLabels: () => labels }, './connect-items': { connectItemFromApi: (item) => ({ ...item, tags: [] }) }, + './cloud-api': api, './encrypted-items.css': {}, }; if (realReact) { imports.react = React; imports['react/jsx-runtime'] = reactRequire('react/jsx-runtime'); } const URLFixture = { createObjectURL: () => 'blob:fixture', revokeObjectURL: (url) => calls.revoked.push(url) }; - const { EncryptedItemsPage } = moduleAt('src/pages/app/EncryptedItemsPage.tsx', imports, { window, document, URL: URLFixture }); + const { EncryptedItems: EncryptedItemsPage } = moduleAt('../shared/cloud/EncryptedItems.tsx', imports, { window, document, URL: URLFixture }); function render() { if (realReact) { renderer.act(() => { - const element = React.createElement(EncryptedItemsPage, { worker, scope: heldScope, onLock: () => locked++ }); + const element = React.createElement(EncryptedItemsPage, { worker, scope: heldScope, api, userId, onLock: () => locked++ }); if (mounted) mounted.update(element); else mounted = renderer.create(element, { createNodeMock: (node) => node.type === 'input' ? fileInput : { querySelector: () => ({ focus() {}, type: 'password' }) } }); }); @@ -111,7 +115,7 @@ function host({ empty = false, attachmentFailure = null, itemFailure = null, hol }; tree = snapshot(mounted.root); return tree; } - slot = 0; tree = EncryptedItemsPage({ worker, scope: heldScope, onLock: () => locked++ }); + slot = 0; tree = EncryptedItemsPage({ worker, scope: heldScope, api, userId, onLock: () => locked++ }); for (const node of all(tree, (node) => !!node.props.ref)) { const ref = node.props.ref; const element = node.type === 'input' ? fileInput : { querySelector: () => ({ focus() {}, type: 'password' }) }; @@ -253,7 +257,7 @@ test('a stale replacement-cancel callback cannot alter a frozen prepared draft', }); test('attachment GET refuses a response beyond its own public envelope bound', async () => { - const api = moduleAt('src/lib/cloud-api.ts', { './auth-api': { authFetch: async () => + const api = apiAt({ './auth-api': { authFetch: async () => new Response('x'.repeat(1404001), { status: 200, headers: { 'Content-Length': '1404001' } }) } }, { Response, atob, btoa }); await assert.rejects(api.getEncryptedAttachment({ passwordBoxId: box, itemId, attachmentId, attachmentVersion: 17, keyEpoch: 1 }, 17, new AbortController().signal), (error) => error.status === 502 && error.message === 'Encrypted storage request failed'); @@ -261,7 +265,7 @@ test('attachment GET refuses a response beyond its own public envelope bound', a test('actual HTTP attachment transport embeds opaque envelopes and validates complete atomic reply', async () => { let reply = { stateRevision: 19, itemVersion: 18, attachmentVersion: 18, keyEpoch: 1 }, seen; - const api = moduleAt('src/lib/cloud-api.ts', { './auth-api': { authFetch: async (url, init) => { + const api = apiAt({ './auth-api': { authFetch: async (url, init) => { seen = { url, init }; return new Response(JSON.stringify(reply), { status: 200 }); } } }, { Response, atob, btoa }); const context = { ...itemContext, attachmentId, attachmentVersion: 18 }; delete context.itemVersion; @@ -278,7 +282,7 @@ test('actual HTTP attachment transport embeds opaque envelopes and validates com test('public attachment state and item snapshot use recipient routes and reject malformed metadata', async () => { let reply = { stateRevision: 17, keyEpoch: 1 }, seen; - const api = moduleAt('src/lib/cloud-api.ts', { './auth-api': { authFetch: async (url, init) => { + const api = apiAt({ './auth-api': { authFetch: async (url, init) => { seen = { url, init }; return new Response(JSON.stringify(reply), { status: 200 }); } } }, { Response, atob, btoa }); const signal = new AbortController().signal; diff --git a/frontend/tests/cross-client-cloud.test.mjs b/frontend/tests/cross-client-cloud.test.mjs new file mode 100644 index 00000000..d4381a1a --- /dev/null +++ b/frontend/tests/cross-client-cloud.test.mjs @@ -0,0 +1,187 @@ +// Controlled renderer/HTTP hosts. These results are not native WebView or PG evidence. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +import http from 'node:http'; +import ts from '../node_modules/typescript/lib/typescript.js'; +import { createRequire } from 'node:module'; +const requireApp = createRequire(new URL('../../apps/mobile/package.json', import.meta.url)); +const React = requireApp('react'), renderer = requireApp('react-test-renderer'); +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +const root = new URL('../../shared/cloud/', import.meta.url); +function load(name, imports = {}, globals = {}) { + const exports = {}; + vm.runInNewContext(ts.transpileModule(fs.readFileSync(new URL(name, root), 'utf8'), { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX }, + }).outputText, { exports, require: (id) => { + if (!(id in imports)) throw new Error(`Unmapped import ${id}`); + return imports[id]; + }, URL, Headers, Response, AbortController, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, + setTimeout, clearTimeout, atob, btoa, ...globals }); + return exports; +} +const cloudApi = load('cloud-api.ts'); +const sharingApi = load('sharing-api.ts', { './cloud-api': cloudApi }); +const sessionModule = (fetch) => load('session-transport.ts', { './cloud-api': cloudApi, './sharing-api': sharingApi }, { fetch }); +const box = 'a'.repeat(26), owner = '11111111-1111-4111-8111-111111111111'; +const flush = () => renderer.act(async () => { for (let n = 0; n < 10; n++) await new Promise(setImmediate); }); +function events() { + const listeners = new Map(); + return { addEventListener(type, fn) { const set = listeners.get(type) ?? new Set(); set.add(fn); listeners.set(type, set); }, + removeEventListener(type, fn) { listeners.get(type)?.delete(fn); }, dispatchEvent(event) { for (const fn of [...(listeners.get(event.type) ?? [])]) fn(event); }, + count: () => [...listeners.values()].reduce((n, set) => n + set.size, 0) }; +} +function host({ mode = 'encrypted', failure = null, delayedUnlock = false, recipient = false } = {}) { + const window = events(), document = { ...events(), visibilityState: 'visible' }; + const input = { value: 'synthetic-encryption-password' }, workers = [], calls = [], legacy = [], unlocks = []; + let release, tree; + class Worker { + constructor() { this.isClosed = false; this.closed = new Promise((resolve) => { this.close = resolve; }); this.ready = Promise.resolve(); workers.push(this); } + dispose() { this.isClosed = true; this.close(); } + async unlock(scope, bundle, secret) { unlocks.push({ scope, bundle, secret }); if (delayedUnlock) await new Promise((resolve) => { release = resolve; }); } + } + const session = { userId: owner, api: { + async getBoxState(id, signal) { calls.push({ id, signal }); if (failure) throw new cloudApi.CloudApiError(failure); return { mode, keyEpoch: mode === 'encrypted' ? 17 : null }; }, + getOwnerBundle: async () => ({ bundle: 'opaque-owner-bundle' }), bundleText: (text) => text, + }, listVaultMembers: async () => [{ userId: owner, role: recipient ? 'member' : 'owner', identityConfirmed: true }], sharing: {} }; + function Items() { return React.createElement('output', null, 'encrypted-items'); } + function Sharing() { return React.createElement('output', null, 'independent-pin-entry'); } + const { CloudBoxPanel } = load('CloudBoxPanel.tsx', { + react: React, 'react/jsx-runtime': requireApp('react/jsx-runtime'), '@shared/i18n/I18nProvider': { useI18n: () => ({ t: (key) => key }) }, + './cloud-api': cloudApi, './cloud-worker': { CloudWorker: Worker }, './EncryptedItems': { EncryptedItems: Items }, + './CloudSharingPanel': { SharedCloudSharingPanel: Sharing }, './client-cloud.css': {}, + }, { window, document }); + const compatibility = (ensure) => { legacy.push(ensure); return React.createElement('output', null, 'compatibility-items'); }; + renderer.act(() => { tree = renderer.create(React.createElement(CloudBoxPanel, { session, boxId: box, compatibility }), { createNodeMock: () => input }); }); + return { tree, input, workers, window, document, legacy, unlocks, calls, session, + setMode(value) { mode = value; }, release: () => release?.(), + async submit() { renderer.act(() => tree.root.findByType('form').props.onSubmit({ preventDefault() {} })); await flush(); }, + async lock() { renderer.act(() => window.dispatchEvent({ type: 'passshare:cloud-lock' })); await flush(); }, + async unmount() { renderer.act(() => tree.unmount()); await flush(); }, + json: () => JSON.stringify(tree.toJSON()) }; +} + +test('simultaneous immutable native sessions use their own session bearer, never cookies or another account', async () => { + const requests = []; + const { createCloudSession } = sessionModule(async (url, init) => { requests.push({ url: url.href, init }); return new Response('{"mode":"encrypted","keyEpoch":17}'); }); + const first = createCloudSession('https://first.example.invalid', 'first', 'session-first'); + const second = createCloudSession('https://second.example.invalid', 'second', 'session-second'); + await Promise.all([first.api.getBoxState(box), second.api.getBoxState(box)]); + assert.deepEqual(requests.map((r) => r.init.headers.get('Authorization')), ['Bearer session-first', 'Bearer session-second']); + assert.deepEqual(requests.map((r) => new URL(r.url).hostname), ['first.example.invalid', 'second.example.invalid']); + for (const { init } of requests) { assert.equal(init.credentials, 'omit'); assert.equal(init.redirect, 'error'); assert.equal(init.cache, 'no-store'); } + assert.equal(Object.isFrozen(first), true); first.dispose(); second.dispose(); +}); + +test('account/epoch change during a held HTTP response cannot return that old response', async () => { + let release, current = true; + const { createCloudSession } = sessionModule(() => new Promise((resolve) => { release = resolve; })); + const session = createCloudSession('https://example.invalid', owner, 'synthetic-session', () => current); + const pending = session.api.getBoxState(box); current = false; + release(new Response('{"mode":"encrypted","keyEpoch":17}')); + await assert.rejects(pending, (error) => error.status === 401); + session.dispose(); +}); + +test('dispose aborts a held request and rejects later requests before network activity', async () => { + let signal, sends = 0; + const { createCloudSession } = sessionModule((_url, init) => { sends++; signal = init.signal; return new Promise((_resolve, reject) => signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true })); }); + const session = createCloudSession('https://example.invalid', owner, 'synthetic-session'); + const pending = session.api.getBoxState(box); session.dispose(); + assert.equal(signal.aborted, true); await assert.rejects(pending); + await assert.rejects(session.api.getBoxState(box), (error) => error.status === 401); + assert.equal(sends, 1); +}); + +test('real redirect response is not followed and cannot transmit the session bearer to its destination', async () => { + let direct = 0, redirected = 0; + const server = http.createServer((request, response) => { + if (request.url === '/destination') { redirected++; response.end('{}'); } + else { direct++; response.writeHead(302, { Location: '/destination' }); response.end(); } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { createCloudSession } = sessionModule(fetch); + const session = createCloudSession(`http://127.0.0.1:${server.address().port}`, owner, 'synthetic-session'); + try { await assert.rejects(session.api.getBoxState(box)); assert.equal(direct, 1); assert.equal(redirected, 0); } + finally { session.dispose(); await new Promise((resolve) => server.close(resolve)); } +}); + +for (const status of [401, 403, 409, 404]) test(`failed mode ${status} cannot mount compatibility or start a worker`, async () => { + const h = host({ failure: status }); await flush(); + assert.equal(h.legacy.length, 0); assert.equal(h.workers.length, 0); assert.equal(h.tree.root.findAllByType('form').length, 0); + await h.unmount(); assert.equal(h.window.count(), 0); assert.equal(h.document.count(), 0); +}); + +test('compatibility gate sees encryption before any subsequent legacy operation', async () => { + const h = host({ mode: 'compatibility' }); await flush(); + assert.ok(h.legacy.length); const ensure = h.legacy.at(-1); + await ensure(); h.setMode('encrypted'); + await renderer.act(async () => { await assert.rejects(ensure(), (error) => error.status === 409); }); + assert.equal(h.json().includes('compatibility-items'), false); + await assert.rejects(ensure(), (error) => error.status === 409); await h.unmount(); +}); + +for (const end of ['lock', 'background', 'unmount']) test(`held unlock after ${end} cannot reveal a prior worker or draft`, async () => { + const h = host({ delayedUnlock: true }); await flush(); await h.submit(); + assert.equal(h.input.value, ''); assert.equal(h.unlocks[0].scope.keyEpoch, 17); assert.equal(h.legacy.length, 0); + if (end === 'background') { h.document.visibilityState = 'hidden'; renderer.act(() => h.document.dispatchEvent({ type: 'visibilitychange' })); } + else await h[end](); + h.release(); await flush(); assert.equal(h.workers[0].isClosed, true); + assert.equal(h.json().includes('"encrypted-items"'), false); + if (end !== 'unmount') await h.unmount(); +}); + +test('recipient enters independent identity/pin flow without an owner password or legacy items', async () => { + const h = host({ recipient: true }); await flush(); + assert.equal(h.tree.root.findAllByType('input').length, 0); await h.submit(); + assert.equal(h.json().includes('independent-pin-entry'), true); + assert.equal(h.unlocks.length, 0); assert.equal(h.legacy.length, 0); await h.unmount(); +}); + +function backgroundHost() { + let listener, mode = 'compatibility', account = owner, disposed = 0, details = 0, writes = 0; + const chrome = { runtime: { onMessage: { addListener(fn) { listener = fn; } } }, alarms: { onAlarm: { addListener() {} } } }; + class Client { + async listVaults() { return [{ id: box }]; } + async listItems() { return [{ id: 'b'.repeat(26), category: 'LOGIN' }]; } + async getItem() { details++; return { id: 'b'.repeat(26), title: 'Synthetic', username: 'synthetic-user', password: 'synthetic-password', url: 'https://example.invalid' }; } + async createItem() { writes++; } + } + load('../../packages/extension/src/background/service-worker.ts', { + '@passshare/connect': { ConnectClient: Client }, + '../lib/auth-api': { fetchCurrentUser: async () => ({ id: account }), getApiBaseUrl: () => 'https://example.invalid', refreshConnectToken: async () => ({}) }, + '../lib/constants': { OFFSCREEN_CLEAR_CLIPBOARD: 'offscreen' }, + './clipboard-clear': { handleClipboardClearAlarm() {}, scheduleClipboardClear() {} }, + '../lib/connect-items': { connectItemFromApi: (item) => item, connectItemToCreateBody: (body) => body, hostnameMatchesItem: () => true }, + '../lib/password-generator': { generatePassword: () => 'synthetic-generated' }, + '../lib/storage': { clearAllAuthStorage() {}, loadRefreshToken: async () => 'synthetic-session', loadStoredConnectToken: async () => ({ token: 'synthetic-connect' }), saveConnectToken() {} }, + '@shared/lib/connect-session': { async bootstrapConnectSession(storage) { await storage.load(); return { user: { id: account }, accessToken: 'synthetic-connect' }; } }, + '@shared/cloud/session-transport': { createCloudSession: () => ({ userId: account, api: { getBoxState: async () => ({ mode }) }, dispose() { disposed++; } }) }, + }, { chrome }); + return { send: (message) => new Promise((resolve) => listener(message, {}, resolve)), + setMode(value) { mode = value; }, setAccount(value) { account = value; }, + get details() { return details; }, get writes() { return writes; }, get disposed() { return disposed; } }; +} + +test('background discovery excludes encrypted boxes and never returns cached plaintext passwords', async () => { + const h = backgroundHost(); + const first = await h.send({ type: 'GET_CREDENTIALS_FOR_HOST', hostname: 'example.invalid' }); + assert.equal(first.ok, true); assert.equal(first.matches[0].password, ''); assert.equal(first.matches[0].accountId, owner); + assert.equal(h.details, 1); + h.setMode('encrypted'); + const after = await h.send({ type: 'GET_CREDENTIALS_FOR_HOST', hostname: 'example.invalid' }); + assert.equal(after.ok, true); assert.equal(after.matches.length, 0); assert.equal(h.details, 1); assert.equal(h.disposed, 2); +}); + +test('background fill and page save recheck current mode and cannot consume a prior account selection', async () => { + const h = backgroundHost(); + const request = { type: 'FILL_CREDENTIAL', accountId: owner, vaultId: box, itemId: 'b'.repeat(26) }; + const before = await h.send(request); assert.equal(before.ok, true); assert.equal(before.matches[0].password, 'synthetic-password'); + h.setMode('encrypted'); assert.equal((await h.send(request)).ok, false); assert.equal(h.details, 1); + assert.equal((await h.send({ type: 'SAVE_CREDENTIAL', password: 'synthetic-page-secret' })).ok, false); assert.equal(h.writes, 0); + h.setMode('compatibility'); h.setAccount('22222222-2222-4222-8222-222222222222'); + assert.equal((await h.send(request)).ok, false); assert.equal(h.details, 1); + h.setAccount(owner); assert.equal((await h.send(request)).ok, true); assert.equal(h.details, 2); + assert.equal(h.disposed, 5); +}); diff --git a/packages/cloud-wasm/examples/web_transport_acceptance.rs b/packages/cloud-wasm/examples/web_transport_acceptance.rs index 1ff8997d..20088cff 100644 --- a/packages/cloud-wasm/examples/web_transport_acceptance.rs +++ b/packages/cloud-wasm/examples/web_transport_acceptance.rs @@ -26,22 +26,32 @@ process.stdin.on('end', () => { let stage='startup'; void (async () => { let now = 0, wallOffset=0, nextTimer = 0, constructing = null; const window = { addEventListener(type, fn) { if (!listeners.has(type)) listeners.set(type,new Set()); listeners.get(type).add(fn); }, removeEventListener(type, fn) { listeners.get(type)?.delete(fn); }, dispatchEvent(event) { for(const fn of [...(listeners.get(event.type) || [])]) fn(event); } }; - const document = {visibilityState:'visible',addEventListener:window.addEventListener,removeEventListener:window.removeEventListener}; + const document = {baseURI:'https://example.invalid/',visibilityState:'visible',addEventListener:window.addEventListener,removeEventListener:window.removeEventListener}; globalThis.DedicatedWorkerGlobalScope = class { static [Symbol.hasInstance](value) { return value === globalThis; } }; class Worker { constructor() { + stage='bindings-load'; this.dead = false; this.hold = false; this.queue = []; this.mutate = null; this.commands=[]; workers.push(this); constructing = this; - delete require.cache[require.resolve(f.bindings)]; this.api = require(f.bindings); + const bindings=fs.readFileSync(f.bindings,'utf8'); + if(bindings.includes('export class CloudPasswordBox')) { + // Host the already-promoted browser bytes without regenerating assets. + const module={exports:{},globalThis,self:globalThis,DedicatedWorkerGlobalScope:globalThis.DedicatedWorkerGlobalScope,TextEncoder,TextDecoder,Uint8Array,ArrayBuffer}; + vm.runInNewContext(ts.transpileModule(bindings.replaceAll('import.meta.url',"'file:///unused/'"), + {compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}}).outputText,module); + stage='bindings-init';module.bytes=fs.readFileSync(path.join(path.dirname(f.bindings),'passshare_cloud_wasm_bg.wasm')); + vm.runInNewContext('exports.initSync({module:bytes});',module);this.api=module.exports; + } else {delete require.cache[require.resolve(f.bindings)];this.api=require(f.bindings);} globalThis.postMessage = (raw,transfer=[]) => { const self = constructing; if (self) self.emit(structuredClone(raw,{transfer})); }; + stage='worker-start'; this.api.start_worker(); this.handler = globalThis.onmessage; } emit(raw) { queueMicrotask(() => { if (this.hold) this.queue.push(raw); else this.onmessage?.({data:this.mutate ? this.mutate(raw) : raw}); }); } postMessage(raw,transfer=[]) { this.commands.push(JSON.parse(typeof raw==='string'?raw:raw.message).command); constructing = this; this.handler({data:structuredClone(raw,{transfer})}); } terminate() { this.dead = true; } } - const sandbox = {exports:{},Worker,window,document,TextEncoder,Uint8Array,ArrayBuffer,Event:class {constructor(type){this.type=type;}}, + const sandbox = {exports:{},URL,Worker,window,document,TextEncoder,Uint8Array,ArrayBuffer,Event:class {constructor(type){this.type=type;}}, performance:{now:()=>now},Date:{now:()=>now+wallOffset},setTimeout(fn, ms){const id=++nextTimer; timers.set(id,{fn,at:now+ms}); return id;},clearTimeout(id){timers.delete(id);}}; - vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(f.root,'frontend/src/lib/cloud-worker.ts'),'utf8'), + vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(f.root,'shared/cloud/cloud-worker.ts'),'utf8'), {compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}}).outputText,sandbox); const {CloudWorker,lockCloudUi}=sandbox.exports; function tick(ms, fire=true) {now+=ms; if(fire) for(const [id,t] of [...timers]) if(t.at<=now){timers.delete(id);t.fn();}} @@ -123,13 +133,13 @@ process.stdin.on('end', () => { let stage='startup'; void (async () => { invalid.push(await rejected(c.generateIdentity(a))&&w.dead); } // Exact public API routes/response framing and account-scoped pin storage. - stage='http-and-pins'; const source=ts.transpileModule(fs.readFileSync(path.join(f.root,'frontend/src/lib/sharing-api.ts'),'utf8'), + stage='http-and-pins'; const source=ts.transpileModule(fs.readFileSync(path.join(f.root,'shared/cloud/sharing-api.ts'),'utf8'), {compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}}).outputText; const storage=new Map(), calls=[]; let reply=null, status=200, responseHook=null; const apiSandbox={exports:{},TextEncoder,TextDecoder,localStorage:{getItem:k=>storage.get(k)??null,setItem:(k,v)=>storage.set(k,v),removeItem:k=>storage.delete(k)}, require(name){if(name==='./auth-api')return {authFetch:async(url,init)=>{calls.push({url,method:init.method??'GET',body:init.body===undefined?null:JSON.parse(init.body)});if(responseHook)return responseHook(url,init);return new Response(JSON.stringify(reply),{status});}}; if(name==='./cloud-api') return {CloudApiError:class extends Error{constructor(status){super('fixed');this.status=status;}}};throw Error('unexpected module');}}; - vm.runInNewContext(source,apiSandbox);const api=apiSandbox.exports, signal=new AbortController().signal; + vm.runInNewContext(source,apiSandbox);const api=apiSandbox.exports.createSharingApi(apiSandbox.require('./auth-api').authFetch), signal=new AbortController().signal; api.savePreparedOwnPin(a.accountId,own); api.savePeerPin(a.accountId,b.accountId,peer); const pinIsolation=api.readOwnPin(b.accountId)===null&&api.readPeerPin(b.accountId,a.accountId)===null&&api.readPeerPin(a.accountId,b.accountId).keyId===peer.keyId; const record=JSON.parse(prepared.record);api.savePendingIdentity(a.accountId,record); @@ -171,14 +181,15 @@ process.stdin.on('end', () => { let stage='startup'; void (async () => { require(name){if(name==='react')return React;if(name==='react/jsx-runtime')return require(path.join(path.dirname(f.renderer),'react/jsx-runtime')); if(name==='@shared/i18n/I18nProvider')return {useI18n:()=>({t:key=>key})}; if(name==='../../lib/auth-api')return {listVaultMembers:async()=>members}; - if(name==='../../lib/cloud-worker')return sandbox.exports; - if(name==='../../lib/cloud-api')return {...apiSandbox.require('./cloud-api'),getBoxState:async()=>({mode:'encrypted',keyEpoch:f.fixture.owner.keyEpoch})}; + if(name==='./cloud-worker')return sandbox.exports; + if(name==='./cloud-api')return apiSandbox.require('./cloud-api'); if(name==='../../lib/sharing-api')return api;if(name.endsWith('.css'))return {};throw Error('unexpected panel module');}}; - vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(f.root,'frontend/src/pages/app/CloudSharingPanel.tsx'),'utf8'), + vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(f.root,'shared/cloud/CloudSharingPanel.tsx'),'utf8'), {compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.ReactJSX}}).outputText,panelSandbox); - const Panel=panelSandbox.exports.CloudSharingPanel, nodes=new Map(); + const Panel=panelSandbox.exports.SharedCloudSharingPanel, nodes=new Map(); const nodeMock=element=>{const key=element.props.name;if(!key)return {};if(!nodes.has(key))nodes.set(key,{value:''});return nodes.get(key);}; - function Shell(props){React.useLayoutEffect(()=>()=>props.worker.dispose(),[props.worker]);return React.createElement(Panel,{...props,key:props.scope.ownerAccountId});} + const panelApi={getBoxState:async()=>({mode:'encrypted',keyEpoch:f.fixture.owner.keyEpoch})}; + function Shell(props){React.useLayoutEffect(()=>()=>props.worker.dispose(),[props.worker]);return React.createElement(Panel,{...props,key:props.scope.ownerAccountId,api:panelApi,sharing:api,listVaultMembers:async()=>members});} const mount=async(worker,scope=f.fixture.owner)=>{let tree;await act(async()=>{tree=renderer.create(React.createElement(Shell,{worker,scope,onLock:()=>{worker.dispose();}}),{createNodeMock:nodeMock});});return tree;}; const button=(tree,key)=>tree.root.findAllByType('button').find(node=>node.props.children===key); const submit=async(tree)=>act(async()=>{tree.root.findByType('form').props.onSubmit({preventDefault(){}});}); diff --git a/packages/extension/manifest.config.ts b/packages/extension/manifest.config.ts index cfdfb400..d5dbc192 100644 --- a/packages/extension/manifest.config.ts +++ b/packages/extension/manifest.config.ts @@ -5,6 +5,9 @@ export default defineManifest({ name: "PassShare", description: "PassShare password manager browser extension", version: "0.1.0", + content_security_policy: { + extension_pages: "script-src 'self' 'wasm-unsafe-eval'; object-src 'none'; worker-src 'self'; base-uri 'self'", + }, action: { default_popup: "popup.html", default_title: "PassShare", diff --git a/packages/extension/src/background/service-worker.ts b/packages/extension/src/background/service-worker.ts index 80fab46a..0e89e30a 100644 --- a/packages/extension/src/background/service-worker.ts +++ b/packages/extension/src/background/service-worker.ts @@ -31,26 +31,15 @@ import { saveConnectToken, } from "../lib/storage"; import { bootstrapConnectSession } from "@shared/lib/connect-session"; -import { createCachedLookup } from "../lib/cached-lookup"; +import { createCloudSession } from "@shared/cloud/session-transport"; -/** - * Credential discovery decrypts every login item across every vault, so it is - * expensive (O(vaults × items)). Cache the matches per hostname for a short - * window and de-duplicate concurrent lookups so repeated `focusin` events on a - * password field do not each trigger a full re-decrypt. - */ -const CREDENTIAL_CACHE_TTL_MS = 30_000; -const listCredentialMatches = createCachedLookup<CredentialMatch[]>({ - fetcher: fetchCredentialMatches, - ttlMs: CREDENTIAL_CACHE_TTL_MS, - fallback: [], -}); - -async function getConnectClient(): Promise<ConnectClient | null> { +async function getConnectClient() { + let heldToken: string | null = null; const result = await bootstrapConnectSession( { async load() { const refreshToken = await loadRefreshToken(); + heldToken = refreshToken; if (!refreshToken) { return null; } @@ -65,31 +54,36 @@ async function getConnectClient(): Promise<ConnectClient | null> { async clear() { await clearAllAuthStorage(); // The stored user is gone; drop any cached credential matches too. - listCredentialMatches.invalidate(); + }, }, { fetchCurrentUser, refreshConnectToken }, ); - if (!result) { + if (!result || !heldToken) { return null; } - return new ConnectClient({ host: getApiBaseUrl(), token: result.accessToken }); + return { client: new ConnectClient({ host: getApiBaseUrl(), token: result.accessToken }), + cloud: createCloudSession(getApiBaseUrl(), result.user.id, heldToken) }; } async function fetchCredentialMatches( hostname: string, ): Promise<CredentialMatch[] | null> { - const client = await getConnectClient(); - if (!client) { + const auth = await getConnectClient(); + if (!auth) { return null; } + const { client, cloud } = auth; + try { const vaults = await client.listVaults(); const matches: CredentialMatch[] = []; for (const vault of vaults) { + const mode = await cloud.api.getBoxState(vault.id); + if (mode.mode === "encrypted") continue; const items = await client.listItems(vault.id); for (const overview of items) { if (overview.category !== "LOGIN") { @@ -107,14 +101,18 @@ async function fetchCredentialMatches( itemId: mapped.id, vaultId: vault.id, title: mapped.title, - username: mapped.username, - password: mapped.password, - url: mapped.url, + username: mapped.username ?? "", + // Discovery returns display metadata only. Selection performs a fresh + // mode/session check before any plaintext is sent to the page. + password: "", + url: mapped.url ?? null, + accountId: cloud.userId, }); } } return matches; + } finally { cloud.dispose(); } } async function saveCredential(payload: { @@ -123,17 +121,21 @@ async function saveCredential(payload: { password: string; url: string; }): Promise<void> { - const client = await getConnectClient(); - if (!client) { + const auth = await getConnectClient(); + if (!auth) { throw new Error("Not authenticated"); } + const { client, cloud } = auth; + try { const vaults = await client.listVaults(); const vault = vaults[0]; if (!vault) { throw new Error("No password box available"); } + const mode = await cloud.api.getBoxState(vault.id); + if (mode.mode !== "compatibility") throw new Error("Encrypted autofill is unavailable"); const body = connectItemToCreateBody({ title: payload.title, category: "LOGIN", @@ -147,9 +149,8 @@ async function saveCredential(payload: { }); await client.createItem(vault.id, body); - // A newly saved login can match a page the user just visited, so drop the - // cached matches to force a fresh lookup on the next focus. - listCredentialMatches.invalidate(); + // No plaintext discovery cache is retained between page requests. + } finally { cloud.dispose(); } } /** @@ -206,8 +207,8 @@ chrome.runtime.onMessage.addListener((message: BackgroundRequest, _sender, sendR switch (message.type) { case "GET_CREDENTIALS_FOR_HOST": { - const matches = await listCredentialMatches(message.hostname); - response = { ok: true, matches }; + const matches = await fetchCredentialMatches(message.hostname); + response = { ok: true, matches: matches ?? [] }; break; } case "GENERATE_PASSWORD": { @@ -225,11 +226,16 @@ chrome.runtime.onMessage.addListener((message: BackgroundRequest, _sender, sendR break; } case "FILL_CREDENTIAL": { - const client = await getConnectClient(); - if (!client) { + const auth = await getConnectClient(); + if (!auth) { response = { ok: false, error: "Not authenticated" }; break; } + const { client, cloud } = auth; + try { + if (cloud.userId !== message.accountId) throw new Error("Not authenticated"); + const mode = await cloud.api.getBoxState(message.vaultId); + if (mode.mode !== "compatibility") throw new Error("Encrypted autofill is unavailable"); const item = await client.getItem(message.vaultId, message.itemId); const mapped = connectItemFromApi(item); response = { @@ -239,12 +245,14 @@ chrome.runtime.onMessage.addListener((message: BackgroundRequest, _sender, sendR itemId: mapped.id, vaultId: message.vaultId, title: mapped.title, - username: mapped.username, - password: mapped.password, - url: mapped.url, + username: mapped.username ?? "", + password: mapped.password ?? "", + url: mapped.url ?? null, + accountId: cloud.userId, }, ], }; + } finally { cloud.dispose(); } break; } default: diff --git a/packages/extension/src/content/autofill.ts b/packages/extension/src/content/autofill.ts index aad276fb..a2659786 100644 --- a/packages/extension/src/content/autofill.ts +++ b/packages/extension/src/content/autofill.ts @@ -85,8 +85,10 @@ function sendBackgroundMessage<T extends BackgroundResponse>( // Cleanup for listeners bound outside the panel element (e.g. on the password // field), invoked when the panel is torn down so nothing leaks between renders. let panelCleanup: (() => void) | null = null; +let panelGeneration = 0; function removePanel(): void { + ++panelGeneration; panelCleanup?.(); panelCleanup = null; document.getElementById(PANEL_ID)?.remove(); @@ -110,6 +112,7 @@ function fillInputs(username: string, password: string): void { function renderPanel(matches: CredentialMatch[]): void { removePanel(); + const generation = panelGeneration; if (matches.length === 0) { return; } @@ -157,9 +160,19 @@ function renderPanel(matches: CredentialMatch[]): void { option.style.cssText = "display:block;width:100%;text-align:left;background:#111827;border:1px solid #1f2937;color:inherit;border-radius:8px;padding:8px;margin:0 0 6px;cursor:pointer"; option.innerHTML = `<strong>${escapeHtml(match.title)}</strong><br><span>${escapeHtml(match.username || t("extension.autofill.noUsername"))}</span>`; - const choose = (): void => { - fillInputs(match.username, match.password); - removePanel(); + const choose = async (): Promise<void> => { + const response = await sendBackgroundMessage<{ ok: boolean; matches?: CredentialMatch[] }>({ + type: "FILL_CREDENTIAL", accountId: match.accountId, vaultId: match.vaultId, itemId: match.itemId, + }).catch(() => ({ ok: false, matches: [] })); + const fresh = response.matches?.[0]; + try { + if (response.ok && fresh && fresh.accountId === match.accountId && generation === panelGeneration && !document.hidden) { + fillInputs(fresh.username, fresh.password); + removePanel(); + } + } finally { + for (const entry of response.matches ?? []) { entry.password = ""; entry.username = ""; } + } }; option.addEventListener("click", choose); options.push(option); @@ -228,6 +241,7 @@ function escapeHtml(value: string): string { } async function showMatchesForCurrentPage(): Promise<void> { + const generation = panelGeneration; const hostname = window.location.hostname; if (!hostname) { return; @@ -238,13 +252,19 @@ async function showMatchesForCurrentPage(): Promise<void> { hostname, }); - if (response.ok) { + if (response.ok && generation === panelGeneration && !document.hidden) { // Ensure the stored locale has resolved before painting localized UI. await localeReady; - renderPanel(response.matches); + if (generation === panelGeneration && !document.hidden) renderPanel(response.matches); } } +document.addEventListener("visibilitychange", () => { if (document.hidden) removePanel(); }); +window.addEventListener("pagehide", removePanel); +chrome.storage.onChanged.addListener((changes, area) => { + if (area === "local" && STORAGE_KEYS.refreshToken in changes) removePanel(); +}); + function attachPasswordFocusHandler(): void { const requestMatches = debounce(() => { void showMatchesForCurrentPage(); diff --git a/packages/extension/src/lib/constants.ts b/packages/extension/src/lib/constants.ts index ca209f7b..62b56abf 100644 --- a/packages/extension/src/lib/constants.ts +++ b/packages/extension/src/lib/constants.ts @@ -23,6 +23,7 @@ export type MessageType = export const OFFSCREEN_CLEAR_CLIPBOARD = "OFFSCREEN_CLEAR_CLIPBOARD" as const; export type CredentialMatch = { + accountId: string; itemId: string; vaultId: string; title: string; @@ -43,6 +44,7 @@ export type GetCredentialsResponse = { }; export type FillCredentialRequest = { + accountId: string; type: "FILL_CREDENTIAL"; itemId: string; vaultId: string; diff --git a/packages/extension/src/popup/components/ItemsPanel.tsx b/packages/extension/src/popup/components/ItemsPanel.tsx index 260e642c..4eedacad 100644 --- a/packages/extension/src/popup/components/ItemsPanel.tsx +++ b/packages/extension/src/popup/components/ItemsPanel.tsx @@ -1,4 +1,6 @@ -import { useEffect, useMemo, useState } from "react"; +import { useI18n } from "@shared/i18n/I18nProvider"; +import { CloudBoxPanel } from "@shared/cloud/CloudBoxPanel"; +import { useEffect, useMemo, useState, useRef } from "react"; import type { ConnectItem } from "@shared/types/item.js"; import { copyTextWithAutoClear, @@ -36,8 +38,33 @@ async function copySecretWithAutoClear(value: string): Promise<void> { } export function ItemsPanel() { + const { cloudSession, connectClient } = useAuthSession(); + const { t } = useI18n(); + const [boxes, setBoxes] = useState<Array<{ id: string; name: string }>>([]); + const [selected, setSelected] = useState(""); + const [failed, setFailed] = useState(false); + useEffect(() => { + let active = true; + setBoxes([]); setSelected(""); setFailed(false); + if (connectClient) void connectClient.listVaults().then((list) => { + if (active) { setBoxes(list); setSelected(list[0]?.id ?? ""); } + }).catch(() => { if (active) setFailed(true); }); + return () => { active = false; }; + }, [cloudSession, connectClient]); + if (!cloudSession) return <p role="status">{t("session.loading")}</p>; + return <section className="ext-panel"> + <label className="ps-label">{t("app.vault.title")}<select className="ps-input" value={selected} onChange={(event) => setSelected(event.target.value)}> + {boxes.map((box) => <option key={box.id} value={box.id}>{box.name}</option>)} + </select></label> + {failed && <p role="alert">{t("app.item.loadError")}</p>} + {selected && <CloudBoxPanel key={`${cloudSession.userId}:${selected}`} session={cloudSession} boxId={selected} + compatibility={(ensureCompatibility) => <CompatibilityItemsPanel boxId={selected} ensureCompatibility={ensureCompatibility} />} />} + </section>; +} +function CompatibilityItemsPanel({ boxId, ensureCompatibility }: { boxId: string; ensureCompatibility: () => Promise<void> }) { const labels = useExtensionLabels(); const { connectClient } = useAuthSession(); + const alive = useRef(true); const [query, setQuery] = useState(""); const [items, setItems] = useState<ConnectItem[]>([]); const [loading, setLoading] = useState(true); @@ -45,6 +72,8 @@ export function ItemsPanel() { const [generated, setGenerated] = useState<string | null>(null); useEffect(() => { + alive.current = true; + let active = true; void (async () => { if (!connectClient) { setItems([]); @@ -54,7 +83,8 @@ export function ItemsPanel() { setLoading(true); try { - const vaults = await connectClient.listVaults(); + await ensureCompatibility(); + const vaults = [{ id: boxId }]; const allItems: ConnectItem[] = []; for (const vault of vaults) { const overviews = await connectClient.listItems(vault.id); @@ -63,12 +93,13 @@ export function ItemsPanel() { allItems.push(connectItemFromApi(item)); } } - setItems(allItems); - } finally { - setLoading(false); + if (active) setItems(allItems); + } catch { if (active) setItems([]); } finally { + if (active) setLoading(false); } })(); - }, [connectClient]); + return () => { active = false; alive.current = false; setItems([]); setGenerated(null); }; + }, [connectClient, boxId, ensureCompatibility]); const filtered = useMemo(() => { const needle = query.trim().toLowerCase(); @@ -78,7 +109,7 @@ export function ItemsPanel() { return items.filter( (item) => item.title.toLowerCase().includes(needle) || - item.username.toLowerCase().includes(needle) || + (item.username ?? "").toLowerCase().includes(needle) || (item.url ?? "").toLowerCase().includes(needle), ); }, [items, query]); @@ -89,6 +120,7 @@ export function ItemsPanel() { // instead of lingering indefinitely. The service-worker alarm also covers // the popup being closed before the delay elapses. await copySecretWithAutoClear(value); + if (!alive.current) return; setCopied(label); window.setTimeout(() => setCopied(null), 1500); } @@ -98,7 +130,7 @@ export function ItemsPanel() { type: "GENERATE_PASSWORD", length: 20, }); - if (response?.ok && typeof response.password === "string") { + if (alive.current && response?.ok && typeof response.password === "string") { setGenerated(response.password); await copySecretWithAutoClear(response.password); } @@ -142,7 +174,7 @@ export function ItemsPanel() { <button type="button" className="ps-button ps-button--secondary" - onClick={() => void copyValue(`${item.id}-user`, item.username)} + onClick={() => void copyValue(`${item.id}-user`, item.username ?? "")} > {copied === `${item.id}-user` ? labels.copied : labels.copyUsername} </button> @@ -151,7 +183,7 @@ export function ItemsPanel() { <button type="button" className="ps-button" - onClick={() => void copyValue(`${item.id}-pw`, item.password)} + onClick={() => void copyValue(`${item.id}-pw`, item.password ?? "")} > {copied === `${item.id}-pw` ? labels.copied : labels.copyPassword} </button> diff --git a/packages/extension/src/popup/hooks/useAuthSession.tsx b/packages/extension/src/popup/hooks/useAuthSession.tsx index c17e9b78..665d8985 100644 --- a/packages/extension/src/popup/hooks/useAuthSession.tsx +++ b/packages/extension/src/popup/hooks/useAuthSession.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useState, + useRef, type ReactNode, } from "react"; import { ConnectClient } from "@passshare/connect"; @@ -28,11 +29,13 @@ import { saveRefreshToken, } from "../../lib/storage"; import { bootstrapConnectSession } from "@shared/lib/connect-session"; +import { createCloudSession, type CloudSession } from "@shared/cloud/session-transport"; type AuthSessionContextValue = { user: AuthUser | null; loading: boolean; connectClient: ConnectClient | null; + cloudSession: CloudSession | null; login: (email: string, password: string) => Promise<void>; signup: (email: string, password: string) => Promise<void>; logout: () => Promise<void>; @@ -43,11 +46,14 @@ const AuthSessionContext = createContext<AuthSessionContextValue | null>(null); async function buildConnectClientFromStorage(): Promise<{ user: AuthUser | null; client: ConnectClient | null; + refreshToken: string | null; }> { + let heldToken: string | null = null; const result = await bootstrapConnectSession( { async load() { const refreshToken = await loadRefreshToken(); + heldToken = refreshToken; if (!refreshToken) { return null; } @@ -65,11 +71,12 @@ async function buildConnectClientFromStorage(): Promise<{ ); if (!result) { - return { user: null, client: null }; + return { user: null, client: null, refreshToken: null }; } return { user: result.user, + refreshToken: heldToken, client: new ConnectClient({ host: getApiBaseUrl(), token: result.accessToken }), }; } @@ -78,21 +85,40 @@ export function AuthSessionProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState<AuthUser | null>(null); const [connectClient, setConnectClient] = useState<ConnectClient | null>(null); const [loading, setLoading] = useState(true); + const [cloudSession, setCloudSession] = useState<CloudSession | null>(null); + const cloudRef = useRef<CloudSession | null>(null); + const generation = useRef(0); + function clearCloud() { + ++generation.current; + cloudRef.current?.dispose(); cloudRef.current = null; setCloudSession(null); + window.dispatchEvent(new Event("passshare:cloud-lock")); + } + function bindCloud(id: string, token: string, epoch: number) { + cloudRef.current = createCloudSession(getApiBaseUrl(), id, token, () => epoch === generation.current); + setCloudSession(cloudRef.current); + } useEffect(() => { + const epoch = generation.current; void (async () => { try { - const { user: storedUser, client } = await buildConnectClientFromStorage(); + const { user: storedUser, client, refreshToken } = await buildConnectClientFromStorage(); + if (epoch !== generation.current) return; + if (storedUser && refreshToken) bindCloud(storedUser.id, refreshToken, epoch); setUser(storedUser); setConnectClient(client); } finally { - setLoading(false); + if (epoch === generation.current) setLoading(false); } })(); + return () => { ++generation.current; cloudRef.current?.dispose(); cloudRef.current = null; }; }, []); - const applyAuth = useCallback(async (response: TokenAuthResponse) => { + const applyAuth = useCallback(async (response: TokenAuthResponse, epoch: number) => { + if (epoch !== generation.current) { await extensionLogout(response.refreshToken).catch(() => {}); return; } await persistAuthSession(response); + if (epoch !== generation.current) return; + bindCloud(response.user.id, response.refreshToken, epoch); setUser(response.user); setConnectClient( new ConnectClient({ host: getApiBaseUrl(), token: response.connectAccessToken }), @@ -101,8 +127,10 @@ export function AuthSessionProvider({ children }: { children: ReactNode }) { const login = useCallback( async (email: string, password: string) => { + clearCloud(); + const epoch = generation.current; try { - await applyAuth(await extensionLogin(email, password)); + await applyAuth(await extensionLogin(email, password), epoch); } catch (error) { if (error instanceof AuthApiError) { throw error; @@ -115,8 +143,10 @@ export function AuthSessionProvider({ children }: { children: ReactNode }) { const signup = useCallback( async (email: string, password: string) => { + clearCloud(); + const epoch = generation.current; try { - await applyAuth(await extensionSignup(email, password)); + await applyAuth(await extensionSignup(email, password), epoch); } catch (error) { if (error instanceof AuthApiError) { throw error; @@ -128,6 +158,8 @@ export function AuthSessionProvider({ children }: { children: ReactNode }) { ); const logout = useCallback(async () => { + clearCloud(); + setUser(null); setConnectClient(null); const refreshToken = await loadRefreshToken(); if (refreshToken) { try { @@ -142,8 +174,8 @@ export function AuthSessionProvider({ children }: { children: ReactNode }) { }, []); const value = useMemo( - () => ({ user, loading, connectClient, login, signup, logout }), - [user, loading, connectClient, login, signup, logout], + () => ({ user, loading, connectClient, cloudSession, login, signup, logout }), + [user, loading, connectClient, cloudSession, login, signup, logout], ); return <AuthSessionContext.Provider value={value}>{children}</AuthSessionContext.Provider>; diff --git a/packages/extension/tsconfig.json b/packages/extension/tsconfig.json index 3c5e43f6..937f109d 100644 --- a/packages/extension/tsconfig.json +++ b/packages/extension/tsconfig.json @@ -8,9 +8,13 @@ "strict": true, "skipLibCheck": true, "noEmit": true, - "types": ["chrome"], + "types": ["chrome", "vite/client"], + "allowImportingTsExtensions": true, "paths": { - "@shared/*": ["../../shared/*"] + "@shared/*": ["../../shared/*"], + "react": ["./node_modules/@types/react/index.d.ts"], + "react/*": ["./node_modules/@types/react/*"], + "@passshare/connect": ["../../packages/connect-js/dist/client.d.ts"] } }, "include": ["src", "manifest.config.ts"] diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index 44da0369..5cdd101e 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -4,15 +4,17 @@ import { crx } from "@crxjs/vite-plugin"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; import { reactResolveFromApp } from "../../shared/vite/react-resolve"; +import { cloudAssets } from "../../shared/vite/cloud-assets"; import manifest from "./manifest.config"; const rootDir = path.dirname(fileURLToPath(import.meta.url)); const reactResolve = reactResolveFromApp(rootDir); export default defineConfig({ - plugins: [react(), crx({ manifest })], + plugins: [react(), cloudAssets(path.resolve(rootDir, "../..")), crx({ manifest })], resolve: { alias: { + "@passshare/connect": path.resolve(rootDir, "../../packages/connect-js/dist/client.js"), "@shared": path.resolve(rootDir, "../../shared"), ...reactResolve.alias, }, diff --git a/shared/cloud/CloudBoxPanel.tsx b/shared/cloud/CloudBoxPanel.tsx new file mode 100644 index 00000000..b7c9da34 --- /dev/null +++ b/shared/cloud/CloudBoxPanel.tsx @@ -0,0 +1,151 @@ +import { useLayoutEffect, useRef, useState, type ReactNode, type FormEvent } from "react"; +import { useI18n } from "@shared/i18n/I18nProvider"; +import type { MessageKey } from "@shared/i18n/index"; +import { CloudApiError, type BoxState } from "./cloud-api"; +import { CloudWorker, type OwnerContext } from "./cloud-worker"; +import type { CloudSession } from "./session-transport"; +import { EncryptedItems } from "./EncryptedItems"; +import { SharedCloudSharingPanel } from "./CloudSharingPanel"; +import "./client-cloud.css"; + +// Unknown/failed/encrypted state never mounts a legacy item renderer. +export function CloudBoxPanel({ session, boxId, compatibility, split = false }: { + session: CloudSession; boxId: string; compatibility?: (ensureCompatibility: () => Promise<void>) => ReactNode; split?: boolean; +}) { + const { t } = useI18n(); + const [state, setState] = useState<BoxState | null>(null); + const [error, setError] = useState<MessageKey | null>(null); + const [busy, setBusy] = useState(false); + const [opened, setOpened] = useState(false); + const [recipient, setRecipient] = useState<boolean | null>(null); + const [scope, setScope] = useState<OwnerContext | null>(null); + const [check, setCheck] = useState(0); + const lifetime = useRef(0); + const alive = useRef(false); + const observedEncrypted = useRef(false); + const worker = useRef<CloudWorker | null>(null); + const operation = useRef<{ controller: AbortController; secret: string } | null>(null); + const checks = useRef(new Set<AbortController>()); + const checkedBinding = useRef<{ session: CloudSession; boxId: string } | null>(null); + const password = useRef<HTMLInputElement>(null); + const binding = useRef({ session, boxId }); + binding.current = { session, boxId }; + const active = (generation: number) => alive.current && generation === lifetime.current && + binding.current.session === session && binding.current.boxId === boxId && document.visibilityState !== "hidden"; + function lock() { + ++lifetime.current; + if (operation.current) { operation.current.secret = ""; operation.current.controller.abort(); } + operation.current = null; + for (const controller of checks.current) controller.abort(); checks.current.clear(); checkedBinding.current = null; + worker.current?.dispose(); worker.current = null; + if (password.current) password.current.value = ""; + setOpened(false); setScope(null); setRecipient(null); setBusy(false); setError(null); setState(null); + if (alive.current) setCheck((n) => n + 1); + } + useLayoutEffect(() => { + alive.current = true; + observedEncrypted.current = false; + lock(); + const background = () => { if (document.visibilityState === "hidden") lock(); else setCheck((n) => n + 1); }; + window.addEventListener("passshare:cloud-lock", lock); + window.addEventListener("pagehide", lock); + document.addEventListener("visibilitychange", background); + return () => { + alive.current = false; lock(); + window.removeEventListener("passshare:cloud-lock", lock); + window.removeEventListener("pagehide", lock); + document.removeEventListener("visibilitychange", background); + }; + }, [session, boxId]); + useLayoutEffect(() => { + if (document.visibilityState === "hidden") return; + const generation = lifetime.current; + const controller = new AbortController(); + checks.current.add(controller); + void session.api.getBoxState(boxId, controller.signal).then(async (next) => { + if (!active(generation) || controller.signal.aborted) return; + if (next.mode === "encrypted") observedEncrypted.current = true; + else if (observedEncrypted.current) throw new CloudApiError(409, "PS056"); + if (next.mode === "encrypted") { + const members = await session.listVaultMembers(boxId, controller.signal); + if (!active(generation) || controller.signal.aborted) return; + const self = members.find((member) => member.userId === session.userId); + if (!self || (self.role !== "owner" && self.identityConfirmed !== true)) throw new CloudApiError(403); + setRecipient(self.role !== "owner"); + } + checkedBinding.current = { session, boxId }; + setState(next); + }).catch(() => { if (active(generation) && !controller.signal.aborted) setError("cloud.failed"); }) + .finally(() => checks.current.delete(controller)); + return () => { controller.abort(); checks.current.delete(controller); }; + }, [session, boxId, check]); + async function ensureCompatibility() { + const generation = lifetime.current; + if (!active(generation) || observedEncrypted.current) throw new CloudApiError(409); + const controller = new AbortController(); + checks.current.add(controller); + try { + const next = await session.api.getBoxState(boxId, controller.signal); + if (!active(generation) || controller.signal.aborted) throw new CloudApiError(401); + if (next.mode !== "compatibility") { observedEncrypted.current = true; setState(next); throw new CloudApiError(409); } + } finally { checks.current.delete(controller); } + } + async function unlock(event: FormEvent) { + event.preventDefault(); + if (busy || worker.current || !state || state.mode !== "encrypted") return; + const task = { controller: new AbortController(), secret: password.current?.value ?? "" }; + if (password.current) password.current.value = ""; + const generation = ++lifetime.current; + const controller = task.controller; + operation.current = task; setBusy(true); setError(null); + const deadline = setTimeout(() => { if (active(generation)) lock(); }, 60_000); + try { + const current = await session.api.getBoxState(boxId, controller.signal); + if (!active(generation)) return; + if (current.mode !== "encrypted" || current.keyEpoch !== state.keyEpoch) throw new CloudApiError(409, "PS056"); + const members = await session.listVaultMembers(boxId, controller.signal); + if (!active(generation)) return; + const self = members.find((member) => member.userId === session.userId); + if (!self || (self.role !== "owner" && self.identityConfirmed !== true)) throw new CloudApiError(403); + const held = { passwordBoxId: boxId, ownerAccountId: session.userId, keyEpoch: state.keyEpoch }; + const crypto = new CloudWorker(); worker.current = crypto; + void crypto.closed.then(() => { if (active(generation) && worker.current === crypto) lock(); }); + await crypto.ready; + if (!active(generation)) return; + if (self.role === "owner") { + const saved = await session.api.getOwnerBundle(boxId, controller.signal); + if (!active(generation)) return; + await crypto.unlock(held, session.api.bundleText(saved.bundle), task.secret); + task.secret = ""; + if (!active(generation)) return; + setOpened(true); + } + setScope(held); setRecipient(self.role !== "owner"); + } catch (failure) { + if (active(generation)) { worker.current?.dispose(); worker.current = null; setError(failure instanceof CloudApiError && failure.status === 403 ? "cloud.ownerOnly" : "cloud.failed"); } + } finally { + clearTimeout(deadline); task.secret = ""; + if (active(generation)) { operation.current = null; setBusy(false); } + } + } + if (checkedBinding.current?.session !== session || checkedBinding.current.boxId !== boxId) { + return <section>{error ? <><p role="alert">{t(error)}</p><button type="button" className="ps-button" onClick={lock}>{t("cloud.retry")}</button></> : <p role="status">{t("session.loading")}</p>}</section>; + } + if (state?.mode === "compatibility") return <>{compatibility ? compatibility(ensureCompatibility) : <p>{t("cloud.unavailable")}</p>}</>; + return <section className="cloud-page client-cloud" aria-busy={busy}> + <header className="encrypted-items-header"><h2>{t("app.item.listHeading")}</h2> + <button type="button" className="ps-button ps-button--secondary" onClick={lock}>{t("cloud.lock")}</button></header> + {state?.mode === "encrypted" && scope && worker.current && opened + ? <EncryptedItems worker={worker.current} scope={scope} userId={session.userId} api={session.api} onLock={lock} split={split} /> + : state?.mode === "encrypted" && scope && worker.current && recipient + ? <SharedCloudSharingPanel worker={worker.current} scope={scope} recipient api={session.api} sharing={session.sharing} + listVaultMembers={session.listVaultMembers} onLock={lock} onReceive={() => setOpened(true)} /> + : state?.mode === "encrypted" ? <form onSubmit={(event) => void unlock(event)}> + <p>{t(recipient ? "sharing.lead" : "cloud.openLead")}</p> + {!recipient && <label className="ps-label">{t("cloud.password")}<input ref={password} className="ps-input" type="password" required + autoComplete="off" autoCapitalize="none" spellCheck={false} maxLength={1024} disabled={busy} /></label>} + <button className="ps-button" disabled={busy}>{t("cloud.openSubmit")}</button> + </form> : !error && <p role="status">{t("session.loading")}</p>} + {error && <><p className="ps-error" role="alert">{t(error)}</p><button type="button" className="ps-button ps-button--secondary" onClick={lock}>{t("cloud.retry")}</button></>} + </section>; +} diff --git a/shared/cloud/CloudSharingPanel.tsx b/shared/cloud/CloudSharingPanel.tsx new file mode 100644 index 00000000..a7247d6c --- /dev/null +++ b/shared/cloud/CloudSharingPanel.tsx @@ -0,0 +1,255 @@ +import { useLayoutEffect, useRef, useState, type FormEvent } from "react"; +import { useI18n } from "@shared/i18n/I18nProvider"; +import type { VaultMember } from "./session-transport"; +import { CloudWorker, type PublicIdentity, type OwnerContext } from "./cloud-worker"; +import { CloudApiError, type CloudApi } from "./cloud-api"; +import type { SharingApi, IdentityRecord } from "./sharing-api"; +import "./cloud-sharing.css"; + +// This component only renders and sequences transport. Rust compares trust +// codes, selects shared context and authenticates the complete item/key data. +export function SharedCloudSharingPanel({ worker, scope, recipient = false, onReceive, onLock, api, sharing, listVaultMembers }: { + worker: CloudWorker; scope: OwnerContext; recipient?: boolean; api: CloudApi; sharing: SharingApi; listVaultMembers: (boxId: string, signal: AbortSignal) => Promise<VaultMember[]>; + onReceive?: () => void; onLock: () => void; +}) { + const { getBoxState } = api; + const { clearPendingIdentity, createIdentity, getIdentity, getMemberIdentity, getSelfGrant, getSnapshot, getSnapshotMembers, publicText, putGrant, readOwnPin, readPeerPin, readPendingIdentity, savePreparedOwnPin, savePeerPin, savePendingIdentity } = sharing; + const { t } = useI18n(); + const [identity, setIdentity] = useState<PublicIdentity | null>(null); + const [members, setMembers] = useState<VaultMember[]>([]); + const [peer, setPeer] = useState(""); + const [trusted, setTrusted] = useState<PublicIdentity | null>(null); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(false); + const [complete, setComplete] = useState(false); + const [ownCodeNeeded, setOwnCodeNeeded] = useState(false); + const [creating, setCreating] = useState(false); + const [ready, setReady] = useState(false); + const secretInput = useRef<HTMLInputElement>(null); + const confirmation = useRef<HTMLInputElement>(null); + const ownCode = useRef<HTMLInputElement>(null); + const peerCode = useRef<HTMLTextAreaElement>(null); + const lifetime = useRef(0); + const task = useRef<{ controller: AbortController; secret: string; code: string } | null>(null); + const loaded = useRef(false); + const account = scope.ownerAccountId; + function clearInputs() { + for (const ref of [secretInput, confirmation, ownCode]) if (ref.current) ref.current.value = ""; + if (peerCode.current) peerCode.current.value = ""; + } + function cancel() { + ++lifetime.current; + if (task.current) { task.current.secret = ""; task.current.code = ""; task.current.controller.abort(); } + task.current = null; clearInputs(); setIdentity(null); setTrusted(null); setBusy(false); setComplete(false); + worker.dispose(); onLock(); + } + useLayoutEffect(() => { + const version = ++lifetime.current; + const controller = new AbortController(); + loaded.current = false; + setReady(false); + void (async () => { + try { + const record = await getIdentity(controller.signal); + const list = await listVaultMembers(scope.passwordBoxId, controller.signal); + if (controller.signal.aborted || lifetime.current !== version || worker.isClosed) return; + setMembers(list); + const owner = list.find((m) => m.role === "owner"); + if (recipient) setPeer(owner?.userId ?? ""); + setCreating(!record && !readPendingIdentity(account)); + const pin = readOwnPin(account); + const held = record ?? readPendingIdentity(account); + setOwnCodeNeeded(!!held && (!pin || publicText(pin) !== publicText(held.identity))); + loaded.current = true; + setReady(true); + } catch { if (!controller.signal.aborted && lifetime.current === version) setFailed(true); } + })(); + return () => { + ++lifetime.current; controller.abort(); + if (task.current) { task.current.controller.abort(); task.current.secret = ""; task.current.code = ""; } + // The parent owns the supplied worker and terminates it on scope exit. + // React's development effect replay must not terminate that live handle. + task.current = null; clearInputs(); + }; + }, [worker, scope.passwordBoxId, account, scope.keyEpoch, recipient]); + + async function flight(action: (current: NonNullable<typeof task.current>, active: () => boolean) => Promise<void>, secret = "", code = "") { + if (task.current || worker.isClosed || document.visibilityState === "hidden" || !loaded.current) { clearInputs(); return; } + const current = { controller: new AbortController(), secret, code }; + secret = ""; code = ""; + const version = lifetime.current; + const active = () => lifetime.current === version && task.current === current && !worker.isClosed && !current.controller.signal.aborted && document.visibilityState !== "hidden"; + task.current = current; setBusy(true); setFailed(false); + const deadline = setTimeout(() => { if (active()) cancel(); }, 60_000); + try { await action(current, active); } + catch (error) { + if (active()) { + if (error instanceof CloudApiError && [401, 403].includes(error.status)) cancel(); + else setFailed(true); + } + } finally { + clearTimeout(deadline); + current.secret = ""; current.code = ""; + if (active()) { task.current = null; setBusy(false); } + } + } + async function unlockIdentity(event: FormEvent) { + event.preventDefault(); + let secret = secretInput.current?.value ?? ""; + let code = ownCode.current?.value ?? ""; + const mismatch = creating && secret !== confirmation.current?.value; + clearInputs(); + if (mismatch) { secret = ""; code = ""; setFailed(true); return; } + const running = flight(async (current, active) => { + // Serialize initial creation across tabs. Pending retries reuse the same + // ciphertext, including when PUT committed but its response was lost. + if (!navigator.locks) throw new CloudApiError(409); + try { await navigator.locks.request(`passshare.identity-create:${account}`, { signal: current.controller.signal }, async () => { + if (!active()) return; + const saved = await getIdentity(current.controller.signal); + if (!active()) return; + const pending = readPendingIdentity(account); + const pin = readOwnPin(account); + // Another item/recovery transport flight must not turn a caller busy + // rejection into destruction of the valid box and its current draft. + if (worker.isBusy) { current.secret = ""; current.code = ""; setFailed(true); return; } + const prepared = await worker.prepareIdentity(JSON.stringify({ + accountContext: JSON.stringify({ accountId: account, identityVersion: 1 }), + ownPin: pin ? JSON.stringify(pin) : "", remoteRecord: saved ? JSON.stringify(saved) : "", + pendingRecord: pending ? JSON.stringify(pending) : "", verificationCode: current.code, + }), current.secret); + current.secret = ""; current.code = ""; + if (!active()) return; + const record = JSON.parse(prepared.record) as IdentityRecord; + const openedPublic = JSON.parse(prepared.publicIdentity) as PublicIdentity; + if (prepared.hasPending) savePendingIdentity(account, record); + savePreparedOwnPin(account, openedPublic); + if (prepared.requiresCreate) { + await createIdentity(record, current.controller.signal); + if (!active()) return; + } + if (prepared.hasPending) { + const readBack = await getIdentity(current.controller.signal); + if (!active()) return; + if (!readBack || publicText(readBack) !== publicText(record)) throw new CloudApiError(409); + clearPendingIdentity(account, record); + } + if (active()) setIdentity(openedPublic); + }); } catch (error) { if (active()) cancel(); throw error; } + }, secret, code); + secret = ""; code = ""; + await running; + } + async function verifyPeer() { + let code = peerCode.current?.value ?? ""; + if (peerCode.current) peerCode.current.value = ""; + setTrusted(null); setComplete(false); + const running = flight(async (current, active) => { + const descriptor = await getMemberIdentity(scope.passwordBoxId, peer, current.controller.signal); + if (!active()) return; + const verified = await worker.verifyPublicIdentity({ accountId: peer, identityVersion: 1 }, JSON.stringify(descriptor), current.code); + current.code = ""; + if (!active()) return; + const pin = JSON.parse(verified) as PublicIdentity; + savePeerPin(account, peer, pin); setTrusted(pin); + }, "", code); + code = ""; + await running; + } + async function share() { + if (!identity || !trusted || complete) return; + await flight(async (current, active) => { + const state = await getBoxState(scope.passwordBoxId, current.controller.signal); + if (!active()) return; + if (state.mode !== "encrypted" || state.keyEpoch !== scope.keyEpoch) { cancel(); return; } + const descriptor = await getMemberIdentity(scope.passwordBoxId, peer, current.controller.signal); + if (!active()) return; + const pin = readPeerPin(account, peer); + if (!pin || publicText(pin) !== publicText(trusted)) throw new CloudApiError(409); + try { await worker.verifyPublicIdentity({ accountId: peer, identityVersion: trusted.identityVersion }, JSON.stringify(descriptor), trusted.keyId); } + catch { if (active()) setTrusted(null); throw new CloudApiError(409); } + if (!active()) return; + let revision = 0; + let selectionMembers; + if (recipient) { + const list = await listVaultMembers(scope.passwordBoxId, current.controller.signal); + if (!active()) return; + selectionMembers = list.map((m) => ({ accountId: m.userId, role: m.role, membershipConfirmed: m.identityConfirmed === true })); + } else { + const snapshot = await getSnapshot(scope.passwordBoxId, current.controller.signal); + if (!active()) return; + if (snapshot.keyEpoch !== scope.keyEpoch || snapshot.ownerAccountId !== account) { cancel(); return; } + const list = await getSnapshotMembers(scope.passwordBoxId, snapshot, current.controller.signal); + if (!active()) return; + revision = snapshot.stateRevision; + selectionMembers = list.map(({ accountId, role, membershipConfirmed }) => ({ accountId, role, membershipConfirmed })); + } + const context = await worker.sharingContext(JSON.stringify({ passwordBoxId: scope.passwordBoxId, callerAccountId: account, + keyEpoch: scope.keyEpoch, sender: recipient ? trusted : identity, recipient: recipient ? identity : trusted, members: selectionMembers })); + if (!active()) return; + if (recipient) { + const envelope = await getSelfGrant(scope.passwordBoxId, context, current.controller.signal); + if (!active()) return; + await worker.receiveShared(envelope, JSON.stringify(trusted), context); + if (active()) onReceive?.(); + } else { + // Retrying after uncertain HTTP output must reuse the same prepared + // envelope. Persist ciphertext before sending; no new HPKE randomness. + const key = `passshare.grant-pending.v1:${account}:${scope.passwordBoxId}:${peer}:${scope.keyEpoch}`; + if (!navigator.locks) throw new CloudApiError(409); + await navigator.locks.request(key, { signal: current.controller.signal }, async () => { + if (!active()) return; + const prepared = localStorage.getItem(key) ?? await worker.wrapForRecipient(JSON.stringify(trusted), context); + if (!active()) return; + if (new TextEncoder().encode(prepared).length > 2048) throw new CloudApiError(409); + localStorage.setItem(key, prepared); + if (localStorage.getItem(key) !== prepared) throw new CloudApiError(409); + const held = JSON.parse(prepared); + if (publicText(held.context) !== publicText(context)) throw new CloudApiError(409); + await putGrant(scope.passwordBoxId, revision, context, held, current.controller.signal); + if (!active()) return; + if (localStorage.getItem(key) !== prepared) throw new CloudApiError(409); + localStorage.removeItem(key); + }); + } + if (active()) setComplete(true); + }); + } + return <section className="cloud-sharing cloud-card" aria-busy={busy || !ready}> + <h2 className="ps-heading-3">{t("sharing.title")}</h2> + <p>{t("sharing.lead")}</p> + {!identity ? <form onSubmit={(event) => void unlockIdentity(event)}> + <label className="ps-label">{t("sharing.identityPassword")}<input ref={secretInput} className="ps-input" type="password" + name="identity-secret" required maxLength={1024} autoComplete="off" spellCheck={false} autoCapitalize="none" disabled={busy || !ready} /></label> + {creating && <label className="ps-label">{t("cloud.confirmation")}<input ref={confirmation} className="ps-input" type="password" + name="identity-confirmation" required maxLength={1024} autoComplete="off" disabled={busy || !ready} /></label>} + {ownCodeNeeded && <label className="ps-label">{t("sharing.savedOwnCode")}<input ref={ownCode} className="ps-input" + name="own-code" required maxLength={256} autoComplete="off" spellCheck={false} disabled={busy || !ready} /></label>} + <p className="cloud-note">{t("sharing.identitySeparate")}</p> + <button className="ps-button cloud-submit" disabled={busy || !ready} type="submit">{t("cloud.openSubmit")}</button> + </form> : <> + <div className="sharing-step"><h3>{t("sharing.ownCode")}</h3><p>{t("sharing.ownHint")}</p> + <div className="sharing-own"><code className="sharing-code">{identity.keyId.match(/.{1,4}/g)?.map((group, index) => <span key={index}>{group}</span>)}</code> + <button className="ps-button ps-button--secondary" type="button" onClick={() => { + void navigator.clipboard.writeText(identity.keyId).catch(() => setFailed(true)); + }}>{t("sharing.copy")}</button></div></div> + <div className="sharing-step"><h3>{t("sharing.peerCode")}</h3> + <label className="ps-label">{t("sharing.peer")}<select className="ps-input" disabled={busy || recipient} value={peer} onChange={(event) => { + setPeer(event.target.value); setTrusted(null); setComplete(false); if (peerCode.current) peerCode.current.value = ""; + }}><option value="">{t("sharing.selectPeer")}</option>{members.filter((m) => m.userId !== account).map((m) => + <option key={m.userId} value={m.userId}>{m.email}</option>)}</select></label> + <label className="ps-label">{t("sharing.peerHint")}<textarea ref={peerCode} className="ps-input" name="peer-code" maxLength={256} + autoComplete="off" spellCheck={false} disabled={busy} onInput={() => { setTrusted(null); setComplete(false); }} /></label> + <div className="sharing-actions"><button className="ps-button" type="button" disabled={busy || !peer} onClick={() => void verifyPeer()}>{t("sharing.verify")}</button> + <button className="ps-button ps-button--secondary" type="button" onClick={cancel}>{t("sharing.cancel")}</button></div> + </div> + <p className="cloud-note">{t(trusted ? "sharing.verified" : "sharing.disabled")}</p> + <button className="ps-button cloud-submit" type="button" disabled={busy || !trusted || complete} onClick={() => void share()}>{t(recipient ? "cloud.openSubmit" : "sharing.start")}</button> + {complete && <p role="status">{t("sharing.complete")}</p>} + </>} + <p className="cloud-note">{t("sharing.irrevocable")}</p> + <p className="cloud-note">{t("sharing.rotationPending")}</p> + {failed && <p className="ps-error" role="alert">{t("sharing.failed")}</p>} + {!identity && <button className="ps-button ps-button--secondary" type="button" onClick={cancel}>{t("sharing.cancel")}</button>} + </section>; +} diff --git a/shared/cloud/EncryptedItems.tsx b/shared/cloud/EncryptedItems.tsx new file mode 100644 index 00000000..d9a0a906 --- /dev/null +++ b/shared/cloud/EncryptedItems.tsx @@ -0,0 +1,551 @@ +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import type { Item } from "@passshare/connect"; +import { ItemForm } from "@shared/ui/ItemForm.js"; +import { categoryLabel, emptyFormValues, formValuesFromItem, ITEM_CATEGORIES, + type ConnectItem, type ItemCategory, type ItemFormValues } from "@shared/types/item.js"; +import { useI18n } from "@shared/i18n/I18nProvider"; +import type { MessageKey } from "@shared/i18n/index"; + +import { useItemLabels } from "./useItemLabels"; +import { connectItemFromApi } from "./connect-items"; +import { CloudApiError, type CloudApi, type AttachmentWrite } from "./cloud-api"; +import type { AttachmentContext, CloudWorker, ItemContext, OwnerContext } from "./cloud-worker"; +import "./encrypted-items.css"; + +type Row = { context: ItemContext; originalJSON: string; display: ConnectItem }; +type FileDraft = { key: number; context: AttachmentContext | null; name: string; size: number; file: File | null; removed: boolean; savedName: string; savedSize: number }; +type Draft = { original: Row | null; values: ItemFormValues; files: FileDraft[]; attachmentReady: boolean }; +type Prepared = { kind: "put"; context: ItemContext; expectedVersion: number; envelopeJSON: string } | + { kind: "delete"; context: ItemContext } | + { kind: "attachment"; write: AttachmentWrite; fileKey: number }; +type Runtime = { alive: boolean; busy: boolean; ready: boolean; rows: Row[]; draft: Draft | null; + prepared: Prepared | null; deleting: Row | null; outcome: "none" | "unknown" | "conflict" | "rejected"; + error: MessageKey | null; query: string; category: ItemCategory; revealed: boolean; + controllers: Set<AbortController>; buffers: Set<Uint8Array>; urls: Set<string>; fileKey: number }; +function runtime(): Runtime { + return { alive: true, busy: false, ready: false, rows: [], draft: null, prepared: null, deleting: null, + outcome: "none", error: null, query: "", category: "LOGIN", revealed: false, controllers: new Set(), + buffers: new Set(), urls: new Set(), fileKey: 0 }; +} +function clearFiles(draft: Draft | null) { + if (!draft) return; + for (const file of draft.files) { file.file = null; file.name = ""; file.savedName = ""; } + draft.files = []; +} +function clear(runtime: Runtime) { + runtime.alive = false; + for (const controller of runtime.controllers) controller.abort(); + runtime.controllers.clear(); + for (const bytes of runtime.buffers) if (bytes.byteLength) bytes.fill(0); + runtime.buffers.clear(); + for (const url of runtime.urls) URL.revokeObjectURL(url); + runtime.urls.clear(); + for (const row of runtime.rows) { row.originalJSON = ""; row.display = { ...row.display, title: "", tags: [], + username: null, password: null, body: null, notes: null, url: null }; } + if (runtime.draft) runtime.draft.values = emptyFormValues(); + clearFiles(runtime.draft); + runtime.rows = []; runtime.draft = null; runtime.prepared = null; runtime.deleting = null; + runtime.query = ""; runtime.revealed = false; runtime.busy = false; runtime.ready = false; runtime.error = null; +} + +// The parent supplies an already-unlocked worker and its held, trusted owner +// scope. No Connect credentials, keys, or password inputs enter this component. +export function EncryptedItems({ worker, scope, onLock, userId, api, split = false }: { + worker: CloudWorker; scope: OwnerContext; onLock: () => void; userId: string; api: CloudApi; split?: boolean; +}) { + const { deleteEncryptedItem, getBoxState, getEncryptedItem, listEncryptedItems, putEncryptedItem, readItemEnvelope, validateItemContext, getAttachmentState, getAttachmentItemSnapshot, getEncryptedAttachment, writeEncryptedAttachment } = api; + const { t } = useI18n(); + const labels = useItemLabels(); + const { passwordBoxId, ownerAccountId, keyEpoch } = scope; + const session = useMemo(runtime, [worker, api, passwordBoxId, ownerAccountId, keyEpoch, userId]); + const [, redraw] = useState(0); + const current = useRef({ session, userId: userId }); + current.current = { session, userId: userId }; + const lockRef = useRef(onLock); + lockRef.current = onLock; + const formHost = useRef<HTMLDivElement>(null); + const fileInput = useRef<HTMLInputElement>(null); + const fileTarget = useRef<number | null>(null); + const returnFocus = useRef<HTMLElement | null>(null); + const restoreFocus = useRef(false); + const bindFileInput = useCallback((input: HTMLInputElement | null) => { + const previous = fileInput.current; + if (previous && previous !== input) { previous.value = ""; previous.oncancel = null; fileTarget.current = null; } + fileInput.current = input; + if (input) input.oncancel = () => { input.value = ""; fileTarget.current = null; }; + }, []); + const changed = () => redraw((revision) => revision + 1); + const active = (controller?: AbortController) => session.alive && !worker.isClosed && current.current.session === session && + current.current.userId === ownerAccountId && !controller?.signal.aborted && document.visibilityState !== "hidden"; + useLayoutEffect(() => { + if (restoreFocus.current && active() && !session.draft && !session.busy) { + restoreFocus.current = false; returnFocus.current?.focus(); + } + }, [session.draft, session.busy]); + function clearInput() { if (fileInput.current) fileInput.current.value = ""; fileTarget.current = null; } + function revoke() { clearInput(); clear(session); worker.dispose(); changed(); lockRef.current(); } + + function rowFromJSON(originalJSON: string, context: ItemContext): Row { + // This is a display adapter only. The original worker-validated full JSON is + // kept verbatim and passed back to Rust for lossless edits. + const item = JSON.parse(originalJSON) as Item; + const display = connectItemFromApi(item); + if (display.id !== context.itemId || item.version !== context.itemVersion || + !ITEM_CATEGORIES.includes(display.category)) throw new CloudApiError(502); + return { context, originalJSON, display }; + } + async function fail(error: unknown, controller: AbortController) { + if (!active(controller)) return; + if (error instanceof CloudApiError && (error.status === 401 || error.status === 403 || error.code === "PS056")) { + revoke(); return; + } + if (error instanceof CloudApiError && error.status === 409) { + // Check only public mode/epoch; never replace the draft or its original + // document with a refetched item after a conflict. + try { + const state = await getBoxState(passwordBoxId, controller.signal); + if (!active(controller)) return; + if (state.mode !== "encrypted" || state.keyEpoch !== keyEpoch) { revoke(); return; } + } catch { if (active(controller)) revoke(); return; } + session.outcome = "conflict"; + session.error = "encryptedItems.conflict"; + } else { + // authFetch's CSRF step can hide an HTTP status behind a generic error. + // Verify only public scope when available; a network failure still keeps + // the prepared operation so it can be reconciled without new encryption. + if (error instanceof CloudApiError && error.status === 0) { + try { + const state = await getBoxState(passwordBoxId, controller.signal); + if (!active(controller)) return; + if (state.mode !== "encrypted" || state.keyEpoch !== keyEpoch) { revoke(); return; } + } catch (scopeError) { + if (!active(controller)) return; + if (scopeError instanceof CloudApiError && (scopeError.status === 401 || scopeError.status === 403)) { revoke(); return; } + } + } + const rejected = error instanceof CloudApiError && error.status >= 400 && error.status < 500; + // A rejection of this replay (including 429) says nothing about a prior + // request whose reply was lost. Retain that uncertainty and the exact + // prepared payload until success, a real conflict, or a scope lock. + const uncertain = session.outcome === "unknown" || !rejected; + session.outcome = session.prepared ? (uncertain ? "unknown" : "rejected") : "none"; + session.error = session.outcome === "unknown" ? "encryptedItems.unknown" : "encryptedItems.failed"; + } + changed(); + } + async function oneFlight(action: (controller: AbortController) => Promise<void>) { + if (!active() || session.busy) return; + session.busy = true; + const controller = new AbortController(); + session.controllers.add(controller); + changed(); + try { await action(controller); } + catch (error) { await fail(error, controller); } + finally { + session.controllers.delete(controller); + if (active(controller)) { session.busy = false; changed(); } + } + } + async function load(controller: AbortController) { + if (!active(controller) || session.prepared || session.draft || session.deleting) return; + session.ready = false; session.error = null; session.outcome = "none"; + session.rows = []; + changed(); + let after: string | null = null; + const cursors = new Set<string>(); + const ids = new Set<string>(); + const contexts: ItemContext[] = []; + do { + const page = await listEncryptedItems(passwordBoxId, keyEpoch, after, controller.signal); + if (!active(controller)) return; + for (const metadata of page.items) { + if (ids.has(metadata.itemId)) throw new CloudApiError(502); + ids.add(metadata.itemId); + contexts.push({ passwordBoxId, itemId: metadata.itemId, itemVersion: metadata.itemVersion, keyEpoch }); + } + after = page.nextCursor; + if (after !== null) { + if (cursors.has(after)) throw new CloudApiError(502); + cursors.add(after); + } + } while (after !== null); + for (const context of contexts) { + const envelopeJSON = await getEncryptedItem(context, controller.signal); + if (!active(controller)) return; + const originalJSON = await worker.decrypt(envelopeJSON, context); + if (!active(controller)) return; + session.rows.push(rowFromJSON(originalJSON, context)); + } + if (active(controller)) { session.ready = true; changed(); } + } + + useLayoutEffect(() => { + // Cleanup runs before paint for changed account, box, epoch, or worker. + session.alive = true; + const stop = () => revoke(); + const background = () => { if (document.visibilityState === "hidden") stop(); }; + window.addEventListener("passshare:cloud-lock", stop); + window.addEventListener("pagehide", stop); + window.addEventListener("popstate", stop); + document.addEventListener("visibilitychange", background); + void worker.closed.then(() => { + if (session.alive && current.current.session === session && current.current.userId === ownerAccountId) stop(); + }); + if (active()) void oneFlight(load); + return () => { + clearInput(); + clear(session); + window.removeEventListener("passshare:cloud-lock", stop); + window.removeEventListener("pagehide", stop); + window.removeEventListener("popstate", stop); + document.removeEventListener("visibilitychange", background); + }; + }, [session]); + + function installRow(row: Row) { + session.rows = [...session.rows.filter((prior) => prior.context.itemId !== row.context.itemId), row] + .sort((left, right) => left.context.itemId.localeCompare(right.context.itemId)); + } + function finishDraft() { + clearInput(); clearFiles(session.draft); session.draft = null; + session.revealed = false; + restoreFocus.current = true; + } + function references(row: Row): AttachmentContext[] { + // Display/transport projection only. Rust authenticates and validates these + // references again against the independent item context and held revision. + const item = JSON.parse(row.originalJSON); + return (item.attachments ?? []).map((reference: { attachmentId: string; attachmentVersion: number; keyEpoch: number }) => + ({ passwordBoxId, itemId: row.context.itemId, attachmentId: reference.attachmentId, + attachmentVersion: reference.attachmentVersion, keyEpoch: reference.keyEpoch })); + } + async function attachmentSnapshot(row: Row | null, controller: AbortController) { + const snapshot = await getAttachmentState(passwordBoxId, controller.signal); + if (!active(controller)) return null; + if (snapshot.keyEpoch !== keyEpoch) throw new CloudApiError(409, "PS056"); + const envelope = row ? await getAttachmentItemSnapshot(row.context, snapshot.stateRevision, controller.signal) : null; + if (!active(controller)) return null; + return { revision: snapshot.stateRevision, envelope }; + } + async function readFile(row: Row, context: AttachmentContext, controller: AbortController) { + const snapshot = await attachmentSnapshot(row, controller); + if (!snapshot || !snapshot.envelope) return null; + const envelope = await getEncryptedAttachment(context, snapshot.revision, controller.signal); + if (!active(controller)) return null; + const plaintext = await worker.decryptAttachment(envelope, context, { itemEnvelope: snapshot.envelope, + expectedItemContext: row.context, itemSnapshotRevision: snapshot.revision, attachmentSnapshotRevision: snapshot.revision }); + if (!active(controller)) { worker.releaseAttachment(plaintext); return null; } + return plaintext; + } + async function loadFiles(draft: Draft, controller: AbortController) { + // Rejected attachment access must not discard the current item form. + try { + const snapshot = await attachmentSnapshot(draft.original, controller); + if (!snapshot || !active(controller) || session.draft !== draft) return; + if (draft.original) for (const context of references(draft.original)) { + const plaintext = await readFile(draft.original, context, controller); + if (!plaintext) return; + try { + if (!active(controller) || session.draft !== draft) return; + draft.files.push({ key: ++session.fileKey, context, name: plaintext.name, size: plaintext.bytes.byteLength, + savedName: plaintext.name, savedSize: plaintext.bytes.byteLength, file: null, removed: false }); + } finally { worker.releaseAttachment(plaintext); } + } + if (active(controller) && session.draft === draft) draft.attachmentReady = true; + } catch (error) { + clearFiles(draft); + if (error instanceof CloudApiError && error.status === 403 && active(controller)) session.error = "attachments.unavailable"; + else throw error; + } + } + + async function sendPrepared(controller: AbortController) { + const prepared = session.prepared; + if (!prepared || !active(controller) || session.outcome === "conflict" || session.outcome === "rejected") return; + if (prepared.kind === "delete") { + await deleteEncryptedItem(prepared.context, controller.signal); + if (!active(controller)) return; + session.rows = session.rows.filter((row) => row.context.itemId !== prepared.context.itemId); + session.deleting = null; + } else if (prepared.kind === "attachment") { + await writeEncryptedAttachment(prepared.write, controller.signal); + if (!active(controller)) return; + const originalJSON = await worker.decrypt(prepared.write.itemEnvelope, prepared.write.itemContext); + if (!active(controller)) return; + const draft = session.draft; + const row = rowFromJSON(originalJSON, prepared.write.itemContext); + installRow(row); + if (draft) { + draft.original = row; + const file = draft.files.find((file) => file.key === prepared.fileKey); + if (file) { + if (prepared.write.envelope === null) { + file.file = null; file.name = ""; file.savedName = ""; + draft.files = draft.files.filter((entry) => entry !== file); + } + else { file.context = prepared.write.context; file.file = null; file.removed = false; file.savedName = file.name; file.savedSize = file.size; } + } + } + } else { + const stored = await putEncryptedItem(prepared.context, prepared.expectedVersion, prepared.envelopeJSON, controller.signal); + if (!active(controller)) return; + const originalJSON = await worker.decrypt(stored, prepared.context); + if (!active(controller)) return; + const row = rowFromJSON(originalJSON, prepared.context); + installRow(row); + if (session.draft) session.draft.original = row; + } + session.prepared = null; session.outcome = "none"; session.error = null; session.revealed = false; + changed(); + } + async function saveRemaining(controller: AbortController) { + await sendPrepared(controller); + if (!active(controller) || session.prepared || !session.draft) return; + const draft = session.draft; + while (active(controller) && session.draft === draft) { + const file = draft.files.find((file) => file.file !== null || file.removed); + if (!file) { finishDraft(); changed(); return; } + const original = draft.original; + if (!original) throw new CloudApiError(502); + const snapshot = await attachmentSnapshot(original, controller); + if (!snapshot || !active(controller)) return; + const context = { ...original.context, itemVersion: original.context.itemVersion + 1 }; + const action = file.removed ? "remove" : file.context ? "replace" : "add"; + const itemEnvelope = await worker.editAttachmentItem(original.originalJSON, JSON.stringify({ form: draft.values, action, + ...(file.context ? { attachmentId: file.context.attachmentId } : {}) }), context); + if (!active(controller)) return; + const originalJSON = await worker.decrypt(itemEnvelope, context); + if (!active(controller)) return; + const nextRow = rowFromJSON(originalJSON, context); + const refs = references(nextRow); + const attachmentContext = action === "remove" ? file.context! : action === "add" ? refs[refs.length - 1] : + refs.find((ref) => ref.attachmentId === file.context!.attachmentId)!; + let envelope: string | null = null; + if (!file.removed) { + const bytes = new Uint8Array(await file.file!.arrayBuffer()); + session.buffers.add(bytes); + try { + if (!active(controller) || session.draft !== draft) return; + envelope = await worker.encryptAttachment(file.name, bytes, attachmentContext); + if (!active(controller)) return; + } finally { if (bytes.byteLength) bytes.fill(0); session.buffers.delete(bytes); } + } + session.prepared = { kind: "attachment", fileKey: file.key, write: { context: attachmentContext, + itemContext: context, revision: snapshot.revision, itemEnvelope, expectedVersion: file.context?.attachmentVersion ?? 0, envelope } }; + await sendPrepared(controller); + if (session.prepared || !active(controller)) return; + } + } + function save(values: ItemFormValues) { + if (!active() || session.busy || !session.ready || !session.draft || session.prepared || session.outcome !== "none") return; + session.draft.values = values; + void oneFlight(async (controller) => { + if (!session.prepared) { + const original = session.draft?.original; + let context: ItemContext; + let envelopeJSON: string; + const completeFormValuesJSON = JSON.stringify(values); + if (original) { + if (original.context.itemVersion >= Number.MAX_SAFE_INTEGER) throw new CloudApiError(502); + context = { ...original.context, itemVersion: original.context.itemVersion + 1 }; + envelopeJSON = await worker.editItem(original.originalJSON, completeFormValuesJSON, context); + } else { + envelopeJSON = await worker.createItem(completeFormValuesJSON); + if (!active(controller)) return; + const envelope = readItemEnvelope(envelopeJSON); + // The creation ID is generated by Rust, never by a GET response or UI. + context = { passwordBoxId, itemId: envelope.context.itemId, itemVersion: 1, keyEpoch }; + } + if (!active(controller)) return; + validateItemContext(context); + readItemEnvelope(envelopeJSON, context); + session.prepared = { kind: "put", context, expectedVersion: context.itemVersion - 1, envelopeJSON }; + } + await saveRemaining(controller); + }); + } + function cancel() { + if (!active()) return; + if (session.busy) { revoke(); return; } + finishDraft(); session.deleting = null; + if (session.outcome === "unknown") { + // Discard plaintext draft explicitly, but reconcile the outstanding exact + // operation before allowing another payload or another creation ID. + changed(); return; + } + const conflict = session.outcome === "conflict" || session.outcome === "rejected"; + session.prepared = null; session.outcome = "none"; session.error = null; + changed(); + if (conflict) void oneFlight(load); + } + function startDraft(row: Row | null) { + if (!active() || session.busy || !session.ready || session.prepared || session.draft || session.deleting) return; + session.error = null; session.revealed = false; + returnFocus.current = document.activeElement as HTMLElement; + const draft: Draft = { original: row, values: row ? formValuesFromItem(row.display) : emptyFormValues(session.category), files: [], attachmentReady: false }; + session.draft = draft; + changed(); + void oneFlight(async (controller) => { await loadFiles(draft, controller); if (active(controller)) formHost.current?.querySelector<HTMLInputElement>("input")?.focus(); }); + } + function remove(row: Row) { + if (!active() || session.busy || !session.ready || session.draft || session.prepared || session.deleting) return; + session.deleting = row; session.error = null; changed(); + } + function confirmRemove() { + if (!session.deleting || session.outcome !== "none") return; + void oneFlight(async (controller) => { + session.prepared ??= { kind: "delete", context: { ...session.deleting!.context } }; + await sendPrepared(controller); + }); + } + function chooseFile(target: number | null = null) { + if (!active() || session.busy || session.prepared || session.outcome !== "none" || !session.draft?.attachmentReady) return; + clearInput(); fileTarget.current = target; + if (fileInput.current) { fileInput.current.multiple = target === null; fileInput.current.click(); } + } + function selectedFiles(input: HTMLInputElement) { + const selected = Array.from(input.files ?? []); + const target = fileTarget.current; + clearInput(); + const draft = session.draft; + if (!active() || session.busy || session.prepared || session.outcome !== "none" || !draft?.attachmentReady) return; + if (selected.some((file) => file.size > 1048576)) { session.error = "attachments.tooLarge"; changed(); return; } + if (target !== null) { + const file = draft.files.find((file) => file.key === target); + if (file && selected.length === 1) { file.file = selected[0]; file.name = selected[0].name; file.size = selected[0].size; file.removed = false; } + } else for (const file of selected) draft.files.push({ key: ++session.fileKey, context: null, name: file.name, size: file.size, + savedName: "", savedSize: 0, file, removed: false }); + session.error = null; changed(); + formHost.current?.querySelector<HTMLElement>(".encrypted-attachments__heading")?.focus(); + } + function discardFile(file: FileDraft) { + const draft = session.draft; + if (!active() || session.busy || session.prepared || session.outcome !== "none" || !draft?.files.includes(file)) return; + if (!file.context) { file.file = null; file.name = ""; draft.files = draft.files.filter((entry) => entry !== file); } + else { file.removed = !file.removed; } + changed(); formHost.current?.querySelector<HTMLElement>(".encrypted-attachments__heading")?.focus(); + } + function undoReplace(file: FileDraft) { + if (!active() || session.busy || session.prepared || session.outcome !== "none" || !session.draft?.files.includes(file)) return; + file.file = null; file.name = file.savedName; file.size = file.savedSize; + changed(); formHost.current?.querySelector<HTMLElement>(".encrypted-attachments__heading")?.focus(); + } + function download(file: FileDraft) { + const draft = session.draft; + if (!draft?.original || !draft.files.includes(file) || !file.context || file.removed || file.file) return; + const row = draft.original, context = file.context; + void oneFlight(async (controller) => { + const plaintext = await readFile(row, context, controller); + if (!plaintext) return; + try { + if (!active(controller) || session.draft !== draft) return; + const blob = new Blob([plaintext.bytes.buffer as ArrayBuffer], { type: "application/octet-stream" }); + const url = URL.createObjectURL(blob); + session.urls.add(url); + const anchor = document.createElement("a"); + anchor.href = url; anchor.download = plaintext.name; document.body.append(anchor); + try { anchor.click(); } finally { anchor.remove(); URL.revokeObjectURL(url); session.urls.delete(url); } + } finally { worker.releaseAttachment(plaintext); } + }); + } + // Ref scope guard applies during render, before layout effects can run. + if (!active()) return null; + const blocked = session.busy || !session.ready || !!session.draft || !!session.deleting || !!session.prepared; + const query = session.query.trim().toLocaleLowerCase(); + const rows = session.ready ? session.rows.filter(({ display }) => display.category === session.category && + (!query || [display.title, display.username, display.url, ...display.tags].some((value) => value?.toLocaleLowerCase().includes(query)))) : []; + return <div className={`encrypted-items${split ? " encrypted-items--split" : ""}`} aria-busy={session.busy}> + <p className="app-success" role="status">{t("cloud.opened")}</p> + <input type="search" className="ps-input" aria-label={labels.searchPlaceholder} placeholder={labels.searchPlaceholder} + value={session.query} onChange={(event) => { if (active()) { session.query = event.target.value; changed(); } }} /> + <div className="encrypted-items__toolbar"> + <div className="encrypted-items__categories" role="group" aria-label={t("encryptedItems.categories")}> + {ITEM_CATEGORIES.map((category) => <button key={category} type="button" + className={`ps-button ${category === session.category ? "ps-button--primary" : "ps-button--secondary"}`} + aria-pressed={category === session.category} onClick={() => { if (active()) { session.category = category; changed(); } }}> + {categoryLabel(category, labels)}</button>)} + </div> + <button type="button" className="ps-button ps-button--primary" disabled={blocked} onClick={() => startDraft(null)}>{labels.submitAdd}</button> + <button type="button" className="ps-button ps-button--secondary" disabled={session.busy || !!session.draft || !!session.deleting || !!session.prepared} + onClick={() => void oneFlight(load)}>{t("app.item.reload")}</button> + </div> + {session.busy && <p role="status">{t("cloud.working")}</p>} + {session.outcome === "unknown" && <button type="button" className="ps-button ps-button--secondary" disabled={session.busy} + onClick={() => void oneFlight(session.draft ? saveRemaining : sendPrepared)}>{t("encryptedItems.retrySame")}</button>} + {session.prepared && !session.draft && !session.deleting && + (session.outcome === "conflict" || session.outcome === "rejected") && + <button type="button" className="ps-button ps-button--secondary" disabled={session.busy} + onClick={cancel}>{labels.cancelEdit} / {t("app.item.reload")}</button>} + {session.deleting && <section className="ps-card" aria-label={labels.delete}> + <h3>{session.deleting.display.title}</h3><p>{labels.confirmDelete}</p> + <div className="ps-actions"><button type="button" className="ps-button ps-button--secondary" + disabled={session.busy || session.outcome !== "none"} onClick={confirmRemove}>{labels.delete}</button> + <button type="button" className="ps-button ps-button--secondary" disabled={session.busy} onClick={cancel}>{labels.cancelEdit}</button></div> + </section>} + {session.draft ? <div className="encrypted-items__form" ref={formHost}> + <ItemForm mode={session.draft.original ? "edit" : "create"} values={session.draft.values} + labels={{ ...labels, submitAdd: labels.submitSave }} disabled={session.busy || !!session.prepared || session.outcome !== "none"} + onChange={(values) => { if (active() && !session.busy && !session.prepared && session.draft) { + if (values.category !== session.draft.values.category) { + session.revealed = false; + const input = formHost.current?.querySelector<HTMLInputElement>('input[autocomplete="new-password"]'); + if (input) input.type = "password"; + } + session.draft.values = values; changed(); + } }} + onSubmit={save} onCancel={cancel} /> + <section className="ps-card encrypted-attachments" aria-labelledby="encrypted-attachments-heading" + aria-describedby="encrypted-attachments-limit encrypted-attachments-security"> + <h3 id="encrypted-attachments-heading" className="ps-heading-3 encrypted-attachments__heading" tabIndex={-1}>{t("attachments.heading")}</h3> + <ul>{session.draft.files.map((file) => <li key={file.key} className="encrypted-attachments__row"> + <div className="encrypted-attachments__name"><span>{file.name}</span><small>{file.size} B</small> + {(file.file || file.removed) && <span role="status">{t(file.removed ? "attachments.pendingDelete" : "attachments.pending")}</span>}</div> + <div className="encrypted-attachments__actions" role="group" aria-label={file.name}> + {file.context && !file.file && !file.removed && <button type="button" className="ps-button ps-button--secondary" disabled={session.busy || !!session.prepared} + onClick={() => download(file)}>{t("attachments.download")}</button>} + {!file.removed && <button type="button" className="ps-button ps-button--secondary" disabled={session.busy || !!session.prepared || session.outcome !== "none"} + onClick={() => chooseFile(file.key)}>{t("attachments.replace")}</button>} + {file.context && file.file && <button type="button" className="ps-button ps-button--secondary" disabled={session.busy || !!session.prepared || session.outcome !== "none"} + onClick={() => undoReplace(file)}>{t("attachments.undoReplace")}</button>} + <button type="button" className="ps-button ps-button--secondary" disabled={session.busy || !!session.prepared || session.outcome !== "none"} + onClick={() => discardFile(file)}>{t(file.context ? file.removed ? "attachments.undoDelete" : "attachments.delete" : "attachments.cancel")}</button> + </div> + </li>)}</ul> + <input ref={bindFileInput} type="file" hidden multiple onChange={(event) => selectedFiles(event.currentTarget)} /> + <button type="button" className="ps-button ps-button--secondary" disabled={session.busy || !!session.prepared || !session.draft.attachmentReady || session.outcome !== "none"} + onClick={() => chooseFile()}>{t("attachments.add")}</button> + <p id="encrypted-attachments-limit">{t("attachments.limit")}</p> + <p id="encrypted-attachments-security">{t("attachments.encrypted")}</p> + </section> + {(session.draft.values.category === "LOGIN" || session.draft.values.category === "PASSWORD") && + <button type="button" className="ps-button ps-button--secondary encrypted-items__eye" disabled={session.busy} + aria-pressed={session.revealed} aria-label={t(session.revealed ? "encryptedItems.hidePassword" : "encryptedItems.showPassword")} + onClick={() => { + const input = formHost.current?.querySelector<HTMLInputElement>('input[autocomplete="new-password"]'); + if (input && active()) { session.revealed = !session.revealed; input.type = session.revealed ? "text" : "password"; changed(); } + }}>{t(session.revealed ? "encryptedItems.hidePassword" : "encryptedItems.showPassword")}</button>} + <div className="encrypted-items__footer"> + <button type="button" className="ps-button ps-button--primary" disabled={session.busy || !!session.prepared || session.outcome !== "none"} + onClick={() => { if (active()) formHost.current?.querySelector("form")?.requestSubmit(); }}>{labels.submitSave}</button> + <button type="button" className="ps-button ps-button--secondary" onClick={cancel}>{labels.cancelEdit}</button> + </div> + </div> : null} + {session.ready && (!session.draft || split) && <div className="encrypted-items__list"> + <div className="encrypted-items__row encrypted-items__head" aria-hidden="true"> + <span>{labels.fieldTitle}</span><span>{labels.fieldUsername}</span><span>{labels.fieldPassword}</span><span>{t("encryptedItems.actions")}</span> + </div> + <ul>{rows.map((row) => <li className="encrypted-items__row" key={row.context.itemId}> + <div><strong>{row.display.title}</strong>{row.display.url && <span className="encrypted-items__url">{row.display.url}</span>}</div> + <span><span className="encrypted-items__mobile-label">{labels.fieldUsername}: </span>{row.display.username || labels.notSet}</span> + <span><span className="encrypted-items__mobile-label">{labels.fieldPassword}: </span>{row.display.password ? "••••••••" : labels.notSet}</span> + <div className="encrypted-items__actions"><button type="button" className="ps-button ps-button--secondary" disabled={blocked} + onClick={() => startDraft(row)} aria-label={`${labels.edit}: ${row.display.title}`}>{labels.edit}</button> + <button type="button" className="ps-button ps-button--secondary" disabled={blocked} + onClick={() => remove(row)} aria-label={`${labels.delete}: ${row.display.title}`}>{labels.delete}</button></div> + </li>)}</ul> + {rows.length === 0 && <p>{labels.emptyList}</p>} + </div>} + {session.error && <p className="ps-error" role="alert">{t(session.error)}</p>} + <p className="cloud-note">{t("encryptedItems.deviceOnly")}</p> + </div>; +} diff --git a/shared/cloud/client-cloud.css b/shared/cloud/client-cloud.css new file mode 100644 index 00000000..4b8d22a6 --- /dev/null +++ b/shared/cloud/client-cloud.css @@ -0,0 +1,16 @@ +.client-cloud { min-width: 0; line-break: strict; word-break: normal; } +.client-cloud .encrypted-items-header { display: flex; flex-wrap: wrap; justify-content: space-between; gap: .75rem; } +.client-cloud form, .client-cloud .ps-label { display: grid; gap: .75rem; min-width: 0; } +.client-cloud input, .client-cloud textarea { min-width: 0; max-width: 100%; } +.client-cloud .encrypted-items__form .ps-grid { grid-template-columns: minmax(0, 1fr); } +.client-cloud .encrypted-items__footer { grid-template-columns: minmax(0, 1fr); } +.client-cloud button { min-height: 44px; max-width: 100%; white-space: normal; overflow-wrap: anywhere; } +.client-cloud .encrypted-attachments__name { flex-basis: 7rem; } +@media (min-width: 800px) { + .client-cloud .encrypted-items--split { grid-template-columns: minmax(0, .85fr) minmax(0, 1fr); align-items: start; } + .client-cloud .encrypted-items--split > * { grid-column: 1; } + .client-cloud .encrypted-items--split > .encrypted-items__form { grid-column: 2; grid-row: 2 / span 8; } + .client-cloud .encrypted-items--split > .cloud-note, .client-cloud .encrypted-items--split > .ps-error { grid-column: 1 / -1; } + .client-cloud .encrypted-items--split .encrypted-items__row { grid-template-columns: minmax(0, 1fr); } + .client-cloud .encrypted-items--split .encrypted-items__head { display: none; } +} diff --git a/shared/cloud/cloud-api.ts b/shared/cloud/cloud-api.ts new file mode 100644 index 00000000..46d58135 --- /dev/null +++ b/shared/cloud/cloud-api.ts @@ -0,0 +1,294 @@ +import type { CloudFetch } from "./session-transport"; +import type { AttachmentContext, ItemContext, OwnerContext } from "./cloud-worker"; +export type BoxState = { mode: "compatibility"; keyEpoch: null } | { mode: "encrypted"; keyEpoch: number }; +export type OwnerBundle = { format: number; salt: string; wrapped: { format: number; context: OwnerContext; nonce: string; ciphertext: string } }; +export type EncryptedEnvelope = { format: 1; context: ItemContext; nonce: string; ciphertext: string }; +export type EncryptedItemMetadata = { itemId: string; itemVersion: number; keyEpoch: number }; +export type AttachmentWrite = { context: AttachmentContext; itemContext: ItemContext; revision: number; + itemEnvelope: string; expectedVersion: number; envelope: string | null }; +export class CloudApiError extends Error { + constructor(readonly status: number, readonly code?: string) { super("Encrypted storage request failed"); } +} + +export function createCloudApi(authFetch: CloudFetch) { + + const path = (boxId: string) => `/api/encrypted/boxes/${encodeURIComponent(boxId)}`; + async function request(url: string, init?: RequestInit) { + const response = await authFetch(url, { cache: "no-store", redirect: "error", ...init }); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + // Never reflect remote messages or input in errors. + const remoteCode = body?.code ?? body?.error; + const code = ["PS015", "PS055", "PS056", "PS024", "PS026"].find((known) => + typeof remoteCode === "string" && (remoteCode === known || remoteCode.startsWith(`${known}:`))); + throw new CloudApiError(response.status, code); + } + try { + const reply = await response.json(); + if (!reply || typeof reply !== "object" || Array.isArray(reply)) throw new CloudApiError(502); + return reply; + } catch { throw new CloudApiError(502); } + } + async function getBoxState(boxId: string, signal?: AbortSignal): Promise<BoxState> { + const state = await request(path(boxId), { signal }); + if (state.mode === "compatibility" && state.keyEpoch === null) return state; + if (state.mode === "encrypted" && Number.isSafeInteger(state.keyEpoch) && state.keyEpoch > 0) return state; + throw new CloudApiError(502); + } + async function getAttachmentState(boxId: string, signal: AbortSignal): Promise<{ stateRevision: number; keyEpoch: number }> { + const state = await request(`${path(boxId)}/attachment-state`, { signal }); + if (!fields(state, ["stateRevision", "keyEpoch"]) || + !Number.isSafeInteger(state.stateRevision) || state.stateRevision < 1 || + !Number.isSafeInteger(state.keyEpoch) || state.keyEpoch < 1) throw new CloudApiError(502); + return state; + } + async function getOwnerBundle(boxId: string, signal?: AbortSignal): Promise<{ bundleVersion: number; bundle: OwnerBundle }> { + const reply = await request(`${path(boxId)}/owner-key`, { signal }); + if (!fields(reply, ["bundleVersion", "bundle"]) || !Number.isSafeInteger(reply.bundleVersion) || reply.bundleVersion < 1) throw new CloudApiError(502); + publicOwnerBundle(reply.bundle); + return reply; + } + async function putOwnerBundle(boxId: string, expectedVersion: number, bundle: OwnerBundle, + currentPassword: string, signal: AbortSignal): Promise<void> { + if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 1 || expectedVersion >= Number.MAX_SAFE_INTEGER) + throw new CloudApiError(502); + publicOwnerBundle(bundle); + let body = JSON.stringify({ expectedVersion, bundle, currentPassword }); + currentPassword = ""; + try { + const reply = await request(`${path(boxId)}/owner-key`, { method: "PUT", body, signal }); + if (!fields(reply, ["bundleVersion", "bundle"]) || reply.bundleVersion !== expectedVersion + 1 || + !sameBundle(reply.bundle, bundle)) throw new CloudApiError(502); + publicOwnerBundle(reply.bundle); + } finally { body = ""; } + } + async function setupBox(boxId: string, bundle: OwnerBundle, signal?: AbortSignal): Promise<void> { + const reply = await request(`${path(boxId)}/setup`, { method: "POST", body: `{"bundle":${bundleText(bundle)}}`, signal }); + if (reply.mode !== "encrypted" || reply.keyEpoch !== 1 || reply.bundleVersion !== 1 || + !sameBundle(reply.bundle, bundle)) throw new CloudApiError(502); + } + + // Exact public transport fields only. Rust validates policy, context and ciphertext. + function fields(value: unknown, keys: string[]): boolean { + return !!value && typeof value === "object" && !Array.isArray(value) && + Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); + } + function bundleText(bundle: OwnerBundle): string { + if (!fields(bundle, ["format", "salt", "wrapped"]) || + !fields(bundle.wrapped, ["format", "context", "nonce", "ciphertext"]) || + !fields(bundle.wrapped.context, ["passwordBoxId", "ownerAccountId", "keyEpoch"])) throw new CloudApiError(502); + const { format, salt, wrapped } = bundle; + if (!Number.isSafeInteger(format) || !Number.isSafeInteger(wrapped.format) || + [salt, wrapped.nonce, wrapped.ciphertext].some((value) => typeof value !== "string") || + !Object.entries(wrapped.context).every(([name, value]) => name === "keyEpoch" + ? Number.isSafeInteger(value) : typeof value === "string")) + throw new CloudApiError(502); + // Serialize the complete Rust-produced public envelope uniformly. Domain + // context validation belongs to Rust; no secret or key field is projected. + return JSON.stringify(bundle, (_name, value: unknown) => value && typeof value === "object" + ? Object.fromEntries(Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)) + : value); + } + function sameBundle(left: OwnerBundle, right: OwnerBundle): boolean { + try { return bundleText(left) === bundleText(right); } catch { return false; } + } + const pendingKey = (ownerId: string, boxId: string) => `passshare.cloud-setup.v1:${encodeURIComponent(ownerId)}:${encodeURIComponent(boxId)}`; + function readPendingBundle(ownerId: string, boxId: string): OwnerBundle | null { + try { + const text = localStorage.getItem(pendingKey(ownerId, boxId)); + if (text === null) return null; + const bundle = JSON.parse(text) as OwnerBundle; + bundleText(bundle); + return bundle; + } catch { throw new CloudApiError(409, "PS015"); } + } + function savePendingBundle(ownerId: string, boxId: string, bundle: OwnerBundle) { + const prior = readPendingBundle(ownerId, boxId); + if (prior && !sameBundle(prior, bundle)) throw new CloudApiError(409, "PS015"); + const text = bundleText(bundle); + try { + localStorage.setItem(pendingKey(ownerId, boxId), text); + if (localStorage.getItem(pendingKey(ownerId, boxId)) !== text) throw new CloudApiError(409, "PS015"); + } catch { throw new CloudApiError(409, "PS015"); } + } + function clearPendingBundle(ownerId: string, boxId: string, expected: OwnerBundle) { + const pending = readPendingBundle(ownerId, boxId); + if (pending && !sameBundle(pending, expected)) throw new CloudApiError(409, "PS015"); + try { localStorage.removeItem(pendingKey(ownerId, boxId)); } + catch { throw new CloudApiError(409, "PS015"); } + } + + // Ciphertext-only session transport. These checks concern public routing fields; + // authentication of the envelope and validation of the whole DTO belong to Core. + const validId = (value: unknown): value is string => typeof value === "string" && /^[a-z0-9]{26}$/.test(value); + const positiveInteger = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) > 0; + + function validateItemContext(context: ItemContext): void { + if (!fields(context, ["passwordBoxId", "itemId", "itemVersion", "keyEpoch"]) || + !validId(context.passwordBoxId) || !validId(context.itemId) || + !positiveInteger(context.itemVersion) || !positiveInteger(context.keyEpoch)) throw new CloudApiError(502); + } + function base64Size(value: unknown, min: number, max: number): boolean { + if (typeof value !== "string" || value.length > 4 * Math.ceil(max / 3) || + !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) return false; + try { const bytes = atob(value); return bytes.length >= min && bytes.length <= max && btoa(bytes) === value; } + catch { return false; } + } + // Public file/HTTP shape only; Rust authenticates the ciphertext and trusted scope. + function publicOwnerBundle(value: unknown): OwnerBundle { + const bundle = value as OwnerBundle; + bundleText(bundle); + const context = bundle.wrapped.context; + if (bundle.format !== 1 || bundle.wrapped.format !== 1 || !validId(context.passwordBoxId) || + !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(context.ownerAccountId) || + context.ownerAccountId === "00000000-0000-0000-0000-000000000000" || !positiveInteger(context.keyEpoch) || + !base64Size(bundle.salt, 16, 16) || !base64Size(bundle.wrapped.nonce, 24, 24) || + !base64Size(bundle.wrapped.ciphertext, 48, 48)) throw new CloudApiError(502); + return bundle; + } + function readItemEnvelope(text: string, expected?: ItemContext): EncryptedEnvelope { + let envelope: EncryptedEnvelope; + try { + if (text.length > 1572864) throw new CloudApiError(502); + envelope = JSON.parse(text) as EncryptedEnvelope; + } catch { throw new CloudApiError(502); } + if (!fields(envelope, ["format", "context", "nonce", "ciphertext"]) || envelope.format !== 1 || + !base64Size(envelope.nonce, 24, 24) || !base64Size(envelope.ciphertext, 16, 1048592)) throw new CloudApiError(502); + validateItemContext(envelope.context); + if (expected) { + validateItemContext(expected); + if (envelope.context.keyEpoch !== expected.keyEpoch) throw new CloudApiError(409, "PS056"); + if (envelope.context.passwordBoxId !== expected.passwordBoxId || envelope.context.itemId !== expected.itemId || + envelope.context.itemVersion !== expected.itemVersion || envelope.context.keyEpoch !== expected.keyEpoch) throw new CloudApiError(502); + } + return envelope; + } + async function boundedText(response: Response, limit: number): Promise<string> { + const length = response.headers.get("Content-Length"); + if (length !== null && (!/^\d+$/.test(length) || Number(length) > limit)) throw new CloudApiError(502); + const reader = response.body?.getReader(); + if (!reader) return ""; + const parts: Uint8Array[] = []; + let size = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) throw new CloudApiError(502); + parts.push(value); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const part of parts) { bytes.set(part, offset); offset += part.length; } + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); } + } + async function itemRequest(url: string, init: RequestInit, status = 200, limit = 1572864): Promise<string> { + try { + const response = await authFetch(url, { cache: "no-store", redirect: "error", ...init }); + if (response.status !== status) { + let code: string | undefined; + try { + const body = JSON.parse(await boundedText(response, 65536)); + const remote = body?.code ?? body?.error; + code = ["PS015", "PS056"].find((known) => remote === known || + (typeof remote === "string" && remote.startsWith(`${known}:`))); + } catch { /* Remote diagnostics never leave the transport. */ } + throw new CloudApiError(response.status, code); + } + if (status === 204 && (response.headers.has("Transfer-Encoding") || + (response.headers.has("Content-Length") && response.headers.get("Content-Length") !== "0"))) throw new CloudApiError(502); + return await boundedText(response, status === 204 ? 0 : limit); + } catch (error) { + if (error instanceof CloudApiError) throw error; + throw new CloudApiError(0); + } + } + function itemsPath(boxId: string, itemId?: string): string { + if (!validId(boxId) || (itemId !== undefined && !validId(itemId))) throw new CloudApiError(502); + return `${path(boxId)}/items${itemId === undefined ? "" : `/${itemId}`}`; + } + async function listEncryptedItems(boxId: string, keyEpoch: number, after: string | null, signal: AbortSignal): + Promise<{ items: EncryptedItemMetadata[]; nextCursor: string | null }> { + if (!positiveInteger(keyEpoch) || (after !== null && !validId(after))) throw new CloudApiError(502); + const text = await itemRequest(`${itemsPath(boxId)}${after === null ? "" : `?after=${after}`}`, { signal }); + let page: { items: EncryptedItemMetadata[]; nextCursor: string | null }; + try { page = JSON.parse(text); } catch { throw new CloudApiError(502); } + if (!fields(page, ["items", "nextCursor"]) || !Array.isArray(page.items) || page.items.length > 100) throw new CloudApiError(502); + let previous = after ?? ""; + for (const entry of page.items) { + if (!fields(entry, ["itemId", "itemVersion", "keyEpoch"]) || !validId(entry.itemId) || entry.itemId <= previous || + !positiveInteger(entry.itemVersion) || !positiveInteger(entry.keyEpoch)) throw new CloudApiError(502); + if (entry.keyEpoch !== keyEpoch) throw new CloudApiError(409, "PS056"); + previous = entry.itemId; + } + if (page.items.length === 100 ? page.nextCursor !== previous : page.nextCursor !== null) throw new CloudApiError(502); + return page; + } + async function getEncryptedItem(expected: ItemContext, signal: AbortSignal): Promise<string> { + validateItemContext(expected); + const text = await itemRequest(itemsPath(expected.passwordBoxId, expected.itemId), { signal }); + readItemEnvelope(text, expected); + return text; + } + async function putEncryptedItem(expected: ItemContext, expectedVersion: number, envelopeJSON: string, signal: AbortSignal): Promise<string> { + validateItemContext(expected); + if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 0 || expectedVersion >= Number.MAX_SAFE_INTEGER || + expected.itemVersion !== expectedVersion + 1) throw new CloudApiError(502); + const sent = readItemEnvelope(envelopeJSON, expected); + // Embed the exact prepared worker output, including on explicit retry. + const text = await itemRequest(itemsPath(expected.passwordBoxId, expected.itemId), { + method: "PUT", body: `{"expectedVersion":${expectedVersion},"envelope":${envelopeJSON}}`, signal, + }); + const stored = readItemEnvelope(text, expected); + if (stored.format !== sent.format || stored.nonce !== sent.nonce || stored.ciphertext !== sent.ciphertext) throw new CloudApiError(502); + return text; + } + async function deleteEncryptedItem(expected: ItemContext, signal: AbortSignal): Promise<void> { + validateItemContext(expected); + await itemRequest(itemsPath(expected.passwordBoxId, expected.itemId), { method: "DELETE", + body: JSON.stringify({ expectedVersion: expected.itemVersion, keyEpoch: expected.keyEpoch }), signal }, 204); + } + + // The item and file reads hold the same independently obtained public revision. + async function getAttachmentItemSnapshot(expected: ItemContext, revision: number, signal: AbortSignal): Promise<string> { + validateItemContext(expected); + if (!positiveInteger(revision)) throw new CloudApiError(502); + const text = await itemRequest(`${itemsPath(expected.passwordBoxId, expected.itemId)}/attachment-snapshot?revision=${revision}`, { signal }); + readItemEnvelope(text, expected); + return text; + } + function attachmentPath(context: AttachmentContext): string { + if (!validId(context.attachmentId) || !positiveInteger(context.attachmentVersion) || !positiveInteger(context.keyEpoch)) throw new CloudApiError(502); + return `${itemsPath(context.passwordBoxId, context.itemId)}/attachments/${context.attachmentId}`; + } + async function getEncryptedAttachment(context: AttachmentContext, revision: number, signal: AbortSignal): Promise<string> { + if (!positiveInteger(revision)) throw new CloudApiError(502); + // Forward the opaque response to Rust for full validation and authentication. + return itemRequest(`${attachmentPath(context)}?revision=${revision}`, { signal }, 200, 1404000); + } + async function writeEncryptedAttachment(prepared: AttachmentWrite, signal: AbortSignal): Promise<number> { + const { context, itemContext, revision, itemEnvelope, expectedVersion, envelope } = prepared; + const url = attachmentPath(context); + validateItemContext(itemContext); + if (!positiveInteger(revision) || revision > Number.MAX_SAFE_INTEGER - 2 || itemContext.itemVersion < 2 || + context.passwordBoxId !== itemContext.passwordBoxId || context.itemId !== itemContext.itemId || context.keyEpoch !== itemContext.keyEpoch || + !Number.isSafeInteger(expectedVersion) || expectedVersion < (envelope === null ? 1 : 0) || + (envelope !== null && (expectedVersion >= Number.MAX_SAFE_INTEGER || context.attachmentVersion !== expectedVersion + 1)) || + (envelope === null && context.attachmentVersion !== expectedVersion)) throw new CloudApiError(502); + readItemEnvelope(itemEnvelope, itemContext); + const body = `{"expectedStateRevision":${revision},"item":{"expectedVersion":${itemContext.itemVersion - 1},"envelope":${itemEnvelope}},"expectedVersion":${expectedVersion},${envelope === null ? `"keyEpoch":${context.keyEpoch}` : `"envelope":${envelope}`}}`; + if (new TextEncoder().encode(body).byteLength > 2 * 1024 * 1024) throw new CloudApiError(413); + const text = await itemRequest(url, { method: envelope === null ? "DELETE" : "PUT", body, signal }); + let reply: Record<string, unknown>; + try { reply = JSON.parse(text); } catch { throw new CloudApiError(502); } + if (!fields(reply, ["stateRevision", "itemVersion", "keyEpoch", envelope === null ? "deleted" : "attachmentVersion"]) || + reply.stateRevision !== revision + 2 || reply.itemVersion !== itemContext.itemVersion || reply.keyEpoch !== context.keyEpoch || + (envelope === null ? reply.deleted !== true : reply.attachmentVersion !== context.attachmentVersion)) throw new CloudApiError(502); + return revision + 2; + } + + return Object.freeze({ getBoxState, getAttachmentState, getOwnerBundle, putOwnerBundle, setupBox, bundleText, sameBundle, readPendingBundle, savePendingBundle, clearPendingBundle, validateItemContext, publicOwnerBundle, readItemEnvelope, listEncryptedItems, getEncryptedItem, putEncryptedItem, deleteEncryptedItem, getAttachmentItemSnapshot, getEncryptedAttachment, writeEncryptedAttachment }); +} +export type CloudApi = ReturnType<typeof createCloudApi>; diff --git a/shared/cloud/cloud-sharing.css b/shared/cloud/cloud-sharing.css new file mode 100644 index 00000000..dbc32934 --- /dev/null +++ b/shared/cloud/cloud-sharing.css @@ -0,0 +1,10 @@ +.cloud-sharing { margin-block: 1rem; min-width: 0; } +.sharing-step { border: 1px solid #28596b; border-radius: .5rem; padding: .8rem; display: grid; gap: .6rem; min-width: 0; } +.sharing-step h3 { margin: 0; font-size: 1rem; } +.sharing-own { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .8rem; min-width: 0; } +.sharing-code { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .3rem .8rem; line-height: 1.8; max-width: 100%; } +.sharing-code span { min-width: 0; } +.sharing-actions { display: flex; flex-wrap: wrap; gap: .6rem; } +.sharing-actions > button { flex: 1 1 10rem; } +.cloud-sharing .ps-input { min-width: 0; max-width: 100%; } +@media (max-width: 400px) { .cloud-sharing { padding: .6rem; } .sharing-step { padding: .5rem; } .sharing-own > button { width: 100%; } } diff --git a/shared/cloud/cloud-worker.ts b/shared/cloud/cloud-worker.ts new file mode 100644 index 00000000..3c716682 --- /dev/null +++ b/shared/cloud/cloud-worker.ts @@ -0,0 +1,319 @@ +// Transport only. Rust owns password policy, cryptography and owner validation. +export type OwnerContext = { passwordBoxId: string; ownerAccountId: string; keyEpoch: number }; +export type ItemContext = { passwordBoxId: string; itemId: string; itemVersion: number; keyEpoch: number }; +export type AttachmentContext = { passwordBoxId: string; itemId: string; attachmentId: string; attachmentVersion: number; keyEpoch: number }; +export type AttachmentPlaintext = { name: string; bytes: Uint8Array }; +export type AttachmentSource = { itemEnvelope: string; expectedItemContext: ItemContext; itemSnapshotRevision: number; attachmentSnapshotRevision: number }; +export type AccountContext = { accountId: string; identityVersion: number }; +export type PublicIdentity = AccountContext & { publicKey: string; keyId: string }; +export type SharedContext = { passwordBoxId: string; senderAccountId: string; recipientAccountId: string; + keyEpoch: number; senderIdentityVersion: number; recipientIdentityVersion: number; senderKeyId: string; recipientKeyId: string }; +type Reply = { version: number; requestId: number | null; generation: number; locked: boolean; identityLocked: boolean; ok: boolean; error?: string; bundle?: string; envelope?: string; item?: string; recovery?: string; publicIdentity?: string; sharedContext?: string; identityPrepared?: string; name?: string; bytes?: Uint8Array }; +type Command = "generate" | "wrap" | "unlock" | "recover" | "recoveryMaterial" | "create" | "edit" | "encrypt" | "decrypt" | + "identityGenerate" | "identityPublic" | "identityWrap" | "identityUnlock" | "identityPrepare" | "verifyPublicIdentity" | "sharingContext" | "sharedRecipientWrap" | "sharedReceiveUnlock" | "encryptAttachment" | "decryptAttachment" | "editAttachmentItem"; +const MAX_BODY = 1024 * 1024; +const MAX_ENVELOPE = 1_500_000; +const MAX_MESSAGE = MAX_BODY * 6 + 4096; +const IDLE_TIMEOUT_MS = 5 * 60 * 1000; +const utf8Size = (text: string) => new TextEncoder().encode(text).byteLength; +// A consumer may have transferred a returned buffer again. Detached/empty +// buffers must never prevent cancellation from destroying the private worker. +const wipeBytes = (bytes?: Uint8Array) => { if (bytes?.byteLength) bytes.fill(0); }; + +export class CloudWorkerError extends Error { + constructor() { super("Cloud worker unavailable or request rejected"); } +} + +export class CloudWorker { + private worker: Worker | null = null; + private generation = 0; + private requestId = 0; + private stopped = false; + private opened = false; + private identityOpened = false; + private readonly attachmentBuffers = new Set<Uint8Array>(); + private pending: { id: number; command: Command; generation: number; resolve: (reply: Reply) => void; reject: (error: CloudWorkerError) => void } | null = null; + private timer: ReturnType<typeof setTimeout> | undefined; + private idleTimer: ReturnType<typeof setTimeout> | undefined; + private lastInput: { monotonic: number; wall: number } | null = null; + private readonly inputEvents = ["pointerdown", "keydown", "wheel", "click", "beforeinput", "input", "submit"] as const; + private readonly input = (event: Event) => { + if (!event.isTrusted || document.visibilityState !== "visible" || !this.lastInput) return; + // Check the old deadline before input can extend it. Consume the first + // expired input before a React handler could reveal or submit plaintext. + if (this.expireIdle()) { event.preventDefault(); event.stopImmediatePropagation(); return; } + this.markInput(); + }; + private readonly background = () => { if (document.visibilityState === "hidden") this.dispose(); }; + private readonly leave = () => this.dispose(); + private readyResolve!: () => void; + private readyReject!: (error: CloudWorkerError) => void; + private readyReceived = false; + private closedResolve!: () => void; + readonly ready: Promise<void>; + readonly closed: Promise<void>; + get isClosed(): boolean { return this.stopped; } + get isBusy(): boolean { return this.pending !== null; } + + constructor(assetUrl = new URL("/cloud-crypto/boot.mjs", document.baseURI)) { + this.ready = new Promise((resolve, reject) => { this.readyResolve = resolve; this.readyReject = reject; }); + this.closed = new Promise((resolve) => { this.closedResolve = resolve; }); + // Disposal can happen before a caller starts awaiting startup. + void this.ready.catch(() => {}); + try { + this.worker = new Worker(assetUrl, { type: "module" }); + this.worker.onmessage = (event: MessageEvent<unknown>) => this.receive(event.data); + this.worker.onerror = () => this.dispose(); + this.worker.onmessageerror = () => this.dispose(); + for (const type of this.inputEvents) window.addEventListener(type, this.input, { capture: true, passive: false }); + document.addEventListener("visibilitychange", this.background); + for (const type of ["pagehide", "passshare:cloud-lock"]) window.addEventListener(type, this.leave); + this.armTimeout(); + } catch { this.dispose(); } + } + + private armTimeout() { + clearTimeout(this.timer); + this.timer = setTimeout(() => this.dispose(), 60_000); + } + + private markInput() { + this.lastInput = { monotonic: performance.now(), wall: Date.now() }; + this.armIdleTimeout(); + } + + private armIdleTimeout() { + clearTimeout(this.idleTimer); + if (!this.lastInput) return; + const elapsed = Math.max(performance.now() - this.lastInput.monotonic, Date.now() - this.lastInput.wall); + this.idleTimer = setTimeout(() => { + // A timer may run early; preserve the deadline rather than renew it. + if (!this.expireIdle()) this.armIdleTimeout(); + }, Math.max(1, IDLE_TIMEOUT_MS - elapsed)); + } + + private expireIdle(): boolean { + if (!this.lastInput) return this.stopped; + const monotonic = performance.now() - this.lastInput.monotonic; + const wall = Date.now() - this.lastInput.wall; + // Some platforms pause the monotonic clock during sleep. Either elapsed + // clock expires the session; a backwards clock change also requires unlock. + if (monotonic >= IDLE_TIMEOUT_MS || wall >= IDLE_TIMEOUT_MS || monotonic < 0 || wall < 0) { + this.dispose(); + lockCloudUi(); + return true; + } + return false; + } + + private receive(data: unknown) { + let binary: Uint8Array | undefined; + // Wipe transferred plaintext even when a cancelled/terminated worker emits late. + if (data && typeof data === "object" && "bytes" in data && data.bytes instanceof Uint8Array && data.bytes.buffer instanceof ArrayBuffer) binary = data.bytes; + if (this.stopped || this.expireIdle()) { wipeBytes(binary); return; } + let reply: Reply; + try { + if (binary) { + if (Object.keys(data as object).length !== 2 || !("message" in (data as object)) || binary.byteLength > MAX_BODY || binary.buffer.byteLength !== binary.byteLength) throw new CloudWorkerError(); + data = (data as { message: unknown }).message; + } + if (typeof data !== "string" || data.length > MAX_MESSAGE || utf8Size(data) > MAX_MESSAGE) throw new CloudWorkerError(); + reply = JSON.parse(data) as Reply; + if (!reply || typeof reply !== "object" || Array.isArray(reply) || + Object.keys(reply).some((key) => !["version", "requestId", "generation", "locked", "identityLocked", "ok", "error", "bundle", "envelope", "item", "recovery", "publicIdentity", "sharedContext", "identityPrepared", "name"].includes(key)) || + (reply.requestId !== null && (!Number.isSafeInteger(reply.requestId) || reply.requestId <= 0)) || + (reply.error !== undefined && !["PS051: Invalid encrypted data", "PS057: Password box is locked", "PS057: Account identity is locked", + "PS058: Stale worker generation", "PS059: Password box is already open"].includes(reply.error)) || + (reply.bundle !== undefined && (typeof reply.bundle !== "string" || utf8Size(reply.bundle) > 2048)) || + (reply.envelope !== undefined && (typeof reply.envelope !== "string" || utf8Size(reply.envelope) > MAX_ENVELOPE)) || + (reply.item !== undefined && (typeof reply.item !== "string" || utf8Size(reply.item) > MAX_BODY)) || + (reply.recovery !== undefined && (typeof reply.recovery !== "string" || utf8Size(reply.recovery) > 4096)) || + (reply.publicIdentity !== undefined && (typeof reply.publicIdentity !== "string" || utf8Size(reply.publicIdentity) > 512)) || + (reply.sharedContext !== undefined && (typeof reply.sharedContext !== "string" || utf8Size(reply.sharedContext) > 1024)) || + (reply.identityPrepared !== undefined && (typeof reply.identityPrepared !== "string" || utf8Size(reply.identityPrepared) > 8192)) || + (reply.name !== undefined && (typeof reply.name !== "string" || utf8Size(reply.name) > 4096)) || + ((reply.name !== undefined) !== (binary !== undefined))) throw new CloudWorkerError(); + if (reply.version !== 1 || !Number.isSafeInteger(reply.generation) || reply.generation < 0 || + typeof reply.locked !== "boolean" || typeof reply.identityLocked !== "boolean" || typeof reply.ok !== "boolean") throw new CloudWorkerError(); + } catch { wipeBytes(binary); this.dispose(); return; } + if (binary) this.attachmentBuffers.add(binary); + if (!this.readyReceived) { + if (reply.requestId !== null || reply.generation !== 0 || !reply.locked || !reply.identityLocked || !reply.ok || + reply.error !== undefined || reply.bundle !== undefined || reply.envelope !== undefined || reply.item !== undefined || reply.recovery !== undefined || reply.publicIdentity !== undefined || reply.sharedContext !== undefined || reply.identityPrepared !== undefined || reply.name !== undefined) { this.dispose(); return; } + this.readyReceived = true; + clearTimeout(this.timer); + this.readyResolve(); + return; + } + const pending = this.pending; + if (!pending || reply.requestId !== pending.id) { this.dispose(); return; } + const opens = ["generate", "unlock", "recover", "sharedReceiveUnlock"].includes(pending.command); + const opensIdentity = ["identityGenerate", "identityUnlock", "identityPrepare"].includes(pending.command); + const payload = pending.command === "identityPrepare" ? "identityPrepared" : pending.command === "sharingContext" ? "sharedContext" : ["identityGenerate", "identityUnlock", "identityPublic", "verifyPublicIdentity"].includes(pending.command) ? "publicIdentity" : + pending.command === "decryptAttachment" ? "name" : pending.command === "recoveryMaterial" ? "recovery" : ["wrap", "identityWrap"].includes(pending.command) ? "bundle" : pending.command === "decrypt" ? "item" : + ["create", "edit", "editAttachmentItem", "encrypt", "sharedRecipientWrap", "encryptAttachment"].includes(pending.command) ? "envelope" : null; + if ((reply.ok ? reply.error !== undefined : typeof reply.error !== "string") || + (["bundle", "envelope", "item", "recovery", "publicIdentity", "sharedContext", "identityPrepared", "name"] as const).some((key) => + reply.ok && key === payload ? typeof reply[key] !== "string" : reply[key] !== undefined)) { this.dispose(); return; } + const expected = pending.generation + (reply.ok && (opens || opensIdentity) ? 1 : 0); + if (reply.generation !== expected || reply.locked !== !(this.opened || (reply.ok && opens)) || + reply.identityLocked !== !(this.identityOpened || (reply.ok && opensIdentity))) { this.dispose(); return; } + this.pending = null; + clearTimeout(this.timer); + this.generation = reply.generation; + if (reply.ok && opens) this.opened = true; + if (reply.ok && opensIdentity) this.identityOpened = true; + // Only the first private handle starts the deadline. Later crypto/HTTP + // traffic, including opening the other handle, never extends it. + if (reply.ok && (opens || opensIdentity) && !this.lastInput) this.markInput(); + if (binary) reply.bytes = binary; + if (reply.ok) pending.resolve(reply); + else pending.reject(new CloudWorkerError()); + } + + private async send(command: Command, fields: Record<string, string>, bytes?: Uint8Array): Promise<Reply> { + try { + await this.ready; + if (this.stopped || this.expireIdle() || !this.worker || this.pending) throw new CloudWorkerError(); + // Opening an already-open handle is a caller error, not a reason to + // destroy the valid key or decrypted draft. Rust also rejects replacement. + if (this.opened && ["generate", "unlock", "recover", "sharedReceiveUnlock"].includes(command)) throw new CloudWorkerError(); + if (this.identityOpened && ["identityGenerate", "identityUnlock", "identityPrepare"].includes(command)) throw new CloudWorkerError(); + for (const key of ["item", "values", "envelope", "context", "expectedAttachmentContext", "name", "attachmentSource"] as const) { + const limit = key === "attachmentSource" ? 2_000_000 : key === "envelope" ? MAX_ENVELOPE : ["context", "expectedAttachmentContext"].includes(key) ? 512 : key === "name" ? 4096 : MAX_BODY; + if (key in fields && (typeof fields[key] !== "string" || utf8Size(fields[key]) > limit)) throw new CloudWorkerError(); + } + if (bytes && (!(bytes.buffer instanceof ArrayBuffer) || bytes.byteLength > MAX_BODY)) throw new CloudWorkerError(); + if (this.requestId >= Number.MAX_SAFE_INTEGER) throw new CloudWorkerError(); + const id = ++this.requestId; + return new Promise((resolve, reject) => { + this.pending = { id, command, generation: this.generation, resolve, reject }; + this.armTimeout(); + try { + const message = JSON.stringify({ version: 1, requestId: id, command, expectedGeneration: this.generation, ...fields }); + if (utf8Size(message) > MAX_MESSAGE) throw new CloudWorkerError(); + if (bytes) this.worker!.postMessage({ message, bytes }, [bytes.buffer as ArrayBuffer]); + else this.worker!.postMessage(message); + } catch { this.dispose(); } + }); + } finally { wipeBytes(bytes); for (const key of ["values", "item", "password", "identitySecret", "verificationCode", "name"]) if (key in fields) fields[key] = ""; } + } + + generate(owner: OwnerContext): Promise<void> { + return this.send("generate", { owner: JSON.stringify(owner) }).then(() => {}); + } + wrap(password: string): Promise<string> { + return this.send("wrap", { password }).then((reply) => { + if (typeof reply.bundle !== "string") { this.dispose(); throw new CloudWorkerError(); } + return reply.bundle; + }); + } + unlock(owner: OwnerContext, bundle: string, password: string): Promise<void> { + return this.send("unlock", { owner: JSON.stringify(owner), bundle, password }).then(() => {}); + } + // Client-only material: callers must separate and clear the returned code. + createRecoveryMaterial(): Promise<string> { + return this.send("recoveryMaterial", {}).then((reply) => reply.recovery!); + } + recover(owner: OwnerContext, bundle: string, recoveryCode: string): Promise<void> { + return this.send("recover", { owner: JSON.stringify(owner), bundle, password: recoveryCode }).then(() => {}); + } + createItem(valuesJSON: string): Promise<string> { + return this.send("create", { values: valuesJSON }).then((reply) => reply.envelope!); + } + editItem(originalFullJSON: string, valuesJSON: string, nextItemContext: ItemContext): Promise<string> { + return this.send("edit", { item: originalFullJSON, values: valuesJSON, context: JSON.stringify(nextItemContext) }).then((reply) => reply.envelope!); + } + editAttachmentItem(originalFullJSON: string, valuesJSON: string, nextItemContext: ItemContext): Promise<string> { + return this.send("editAttachmentItem", { item: originalFullJSON, values: valuesJSON, context: JSON.stringify(nextItemContext) }).then((reply) => reply.envelope!); + } + encrypt(itemJSON: string, context: ItemContext): Promise<string> { + return this.send("encrypt", { item: itemJSON, context: JSON.stringify(context) }).then((reply) => reply.envelope!); + } + decrypt(envelopeJSON: string, context: ItemContext): Promise<string> { + return this.send("decrypt", { envelope: envelopeJSON, context: JSON.stringify(context) }).then((reply) => reply.item!); + } + // Takes ownership of input bytes. Context comes from authenticated item references + // and the same revision as the ciphertext fetch; never infer it from the envelope. + encryptAttachment(name: string, input: Uint8Array | ArrayBuffer, expectedAttachmentContext: AttachmentContext): Promise<string> { + if (!(input instanceof Uint8Array) && !(input instanceof ArrayBuffer)) return Promise.reject(new CloudWorkerError()); + const view = input instanceof ArrayBuffer ? new Uint8Array(input) : input; + if (!(view.buffer instanceof ArrayBuffer) || view.byteLength > MAX_BODY) { wipeBytes(view); return Promise.reject(new CloudWorkerError()); } + const bytes = view.byteOffset === 0 && view.byteLength === view.buffer.byteLength ? view : view.slice(); + if (bytes !== view) wipeBytes(view); + try { + return this.send("encryptAttachment", { name, expectedAttachmentContext: JSON.stringify(expectedAttachmentContext) }, bytes).then((r) => r.envelope!); + } catch { wipeBytes(bytes); return Promise.reject(new CloudWorkerError()); } + } + decryptAttachment(envelope: string, expectedAttachmentContext: AttachmentContext, attachmentSource: AttachmentSource): Promise<AttachmentPlaintext> { + return this.send("decryptAttachment", { envelope, expectedAttachmentContext: JSON.stringify(expectedAttachmentContext), attachmentSource: JSON.stringify(attachmentSource) }).then((r) => { + if (this.stopped || this.expireIdle() || r.generation !== this.generation || !r.bytes) { wipeBytes(r.bytes); throw new CloudWorkerError(); } + return { name: r.name!, bytes: r.bytes }; + }); + } + releaseAttachment(plaintext: AttachmentPlaintext) { wipeBytes(plaintext.bytes); this.attachmentBuffers.delete(plaintext.bytes); plaintext.name = ""; } + + generateIdentity(context: AccountContext): Promise<string> { + return this.send("identityGenerate", { accountContext: JSON.stringify(context) }).then((r) => r.publicIdentity!); + } + publicIdentity(): Promise<string> { return this.send("identityPublic", {}).then((r) => r.publicIdentity!); } + prepareIdentity(values: string, identitySecret: string): Promise<{ publicIdentity: string; record: string; requiresCreate: boolean; hasPending: boolean }> { + return this.send("identityPrepare", { values, identitySecret }).then((r) => { + const prepared = JSON.parse(r.identityPrepared!); + if (!prepared || Object.keys(prepared).length !== 4 || typeof prepared.publicIdentity !== "string" || + typeof prepared.record !== "string" || typeof prepared.requiresCreate !== "boolean" || typeof prepared.hasPending !== "boolean") { + this.dispose(); throw new CloudWorkerError(); + } + return prepared; + }); + } + wrapIdentity(identitySecret: string): Promise<string> { + return this.send("identityWrap", { identitySecret }).then((r) => r.bundle!); + } + unlockIdentity(context: AccountContext, expectedKeyId: string, bundle: string, identitySecret: string): Promise<string> { + return this.send("identityUnlock", { accountContext: JSON.stringify(context), expectedKeyId, bundle, identitySecret }).then((r) => r.publicIdentity!); + } + verifyPublicIdentity(context: AccountContext, publicIdentity: string, verificationCode: string): Promise<string> { + return this.send("verifyPublicIdentity", { accountContext: JSON.stringify(context), publicIdentity, verificationCode }).then((r) => r.publicIdentity!); + } + wrapForRecipient(trustedRecipientPublic: string, context: SharedContext): Promise<string> { + return this.send("sharedRecipientWrap", { trustedRecipientPublic, sharedContext: JSON.stringify(context) }).then((r) => r.envelope!); + } + sharingContext(selectionJSON: string): Promise<SharedContext> { + return this.send("sharingContext", { values: selectionJSON }).then((r) => JSON.parse(r.sharedContext!) as SharedContext); + } + receiveShared(envelope: string, trustedSenderPublic: string, context: SharedContext): Promise<void> { + return this.send("sharedReceiveUnlock", { envelope, trustedSenderPublic, sharedContext: JSON.stringify(context) }).then(() => {}); + } + + // Immediate invalidation; termination does not promise a memory-wide wipe. + dispose() { + if (this.stopped) return; + this.stopped = true; + this.opened = false; + this.identityOpened = false; + for (const bytes of this.attachmentBuffers) wipeBytes(bytes); + this.attachmentBuffers.clear(); + clearTimeout(this.timer); + clearTimeout(this.idleTimer); + this.lastInput = null; + for (const type of this.inputEvents) window.removeEventListener(type, this.input, true); + document.removeEventListener("visibilitychange", this.background); + for (const type of ["pagehide", "passshare:cloud-lock"]) window.removeEventListener(type, this.leave); + if (this.worker) { + this.worker.onmessage = null; + this.worker.onerror = null; + this.worker.onmessageerror = null; + this.worker.terminate(); + this.worker = null; + } + this.pending?.reject(new CloudWorkerError()); + this.pending = null; + this.readyReject(new CloudWorkerError()); + this.generation = 0; + this.closedResolve(); + } +} + +// Shell logout invalidates secrets before any HTTP request, even if it fails. +export function lockCloudUi() { window.dispatchEvent(new Event("passshare:cloud-lock")); } diff --git a/shared/cloud/connect-items.ts b/shared/cloud/connect-items.ts new file mode 100644 index 00000000..3528a0b0 --- /dev/null +++ b/shared/cloud/connect-items.ts @@ -0,0 +1,93 @@ +import type { Item } from "@passshare/connect"; +import type { ConnectItem, ItemCategory, ItemFormValues } from "@shared/types/item.js"; +import { parseTagsInput } from "@shared/types/item.js"; + +function fieldValue(item: Item, purpose?: string, label?: string): string { + const fields = item.fields ?? []; + const match = fields.find( + (field) => + (purpose && field.purpose?.toUpperCase() === purpose) || + (label && field.label?.toLowerCase() === label.toLowerCase()), + ); + return match?.value ?? ""; +} + +export function connectItemFromApi(item: Item): ConnectItem { + const category = (item.category ?? "LOGIN") as ItemCategory; + const primaryUrl = item.urls?.find((url) => url.primary) ?? item.urls?.[0]; + + return { + id: item.id, + title: item.title, + category, + tags: item.tags ?? [], + favorite: Boolean(item.favorite), + url: primaryUrl?.href ?? null, + username: fieldValue(item, "USERNAME"), + password: fieldValue(item, "PASSWORD"), + body: fieldValue(item, undefined, "notes") || fieldValue(item, undefined, "body"), + notes: fieldValue(item, undefined, "notes"), + revision: item.version, + updatedAt: item.updatedAt ?? null, + }; +} + +export function connectItemToCreateBody(values: ItemFormValues, original?: Item): Record<string, unknown> { + const tags = original && values.tags === (original.tags ?? []).join(", ") + ? original.tags ?? [] : parseTagsInput(values.tags); + const fields: Array<Record<string, unknown>> = (original?.fields ?? []).map((field) => ({ ...field })); + + // Update only the fields exposed by the form. Imported fields, sections and + // metadata must survive a title/password edit, including their original IDs. + function updateField(label: string, value: string, purpose?: string) { + const index = fields.findIndex((field) => purpose + ? typeof field.purpose === "string" && field.purpose.toUpperCase() === purpose + : typeof field.label === "string" && field.label.toLowerCase() === label); + if (index >= 0) { + fields[index] = { ...fields[index], value }; + } else if (value || !original) { + fields.push({ label, type: purpose === "PASSWORD" ? "CONCEALED" : "STRING", ...(purpose ? { purpose } : {}), value }); + } + } + + if (values.category === "LOGIN") { + updateField("username", values.username, "USERNAME"); + updateField("password", values.password, "PASSWORD"); + } else if (values.category === "PASSWORD") { + updateField("password", values.password, "PASSWORD"); + } else { + const notesField = original?.fields?.find((field) => field.label.toLowerCase() === "notes"); + const bodyField = original?.fields?.find((field) => field.label.toLowerCase() === "body"); + const bodyLabel = notesField?.value || !bodyField ? "notes" : "body"; + updateField(bodyLabel, values.body); + } + + if (values.category === "LOGIN" || values.category === "PASSWORD") { + if (values.notes || original?.fields?.some((field) => field.label.toLowerCase() === "notes")) { + updateField("notes", values.notes); + } + } + + const body: Record<string, unknown> = { + ...original, + title: values.title, + category: values.category, + fields, + tags, + favorite: values.favorite, + }; + + if (values.category === "LOGIN") { + const urls = (original?.urls ?? []).map((url) => ({ ...url })); + const primaryIndex = Math.max(0, urls.findIndex((url) => url.primary)); + if (values.url.trim()) { + if (urls.length) urls[primaryIndex] = { ...urls[primaryIndex], href: values.url.trim() }; + else urls.push({ href: values.url.trim(), primary: true, label: "" }); + } else if (urls.length) { + urls.splice(primaryIndex, 1); + } + body.urls = urls; + } + + return body; +} diff --git a/shared/cloud/encrypted-items.css b/shared/cloud/encrypted-items.css new file mode 100644 index 00000000..0c2b08a0 --- /dev/null +++ b/shared/cloud/encrypted-items.css @@ -0,0 +1,42 @@ +.encrypted-items { display: grid; gap: var(--ps-space-4, 1rem); min-width: 0; } +.cloud-page .encrypted-items-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--ps-space-3, .75rem); min-width: 0; } +.cloud-page .encrypted-items-header h2 { min-width: 0; overflow-wrap: anywhere; } +.cloud-page .encrypted-items-header .cloud-submit { width: auto; max-width: 100%; margin: 0; } +.encrypted-items *, .encrypted-items *::before, .encrypted-items *::after { box-sizing: border-box; } +.encrypted-items__toolbar, .encrypted-items__categories, .encrypted-items__actions { display: flex; flex-wrap: wrap; gap: var(--ps-space-2, .5rem); min-width: 0; } +.encrypted-items__categories { flex: 1 1 100%; } +.encrypted-items .ps-button { max-width: 100%; white-space: normal; overflow-wrap: anywhere; height: auto; min-height: 44px; } +.encrypted-items__list { border: 1px solid var(--ps-border); border-radius: var(--ps-radius-md); overflow: hidden; min-width: 0; } +.encrypted-items__list ul { list-style: none; margin: 0; padding: 0; } +.encrypted-items__row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, .7fr) minmax(0, 1fr); gap: var(--ps-space-3, .75rem); align-items: center; padding: var(--ps-space-3, .75rem); border-bottom: 1px solid var(--ps-border); } +.encrypted-items__row > * { min-width: 0; overflow-wrap: anywhere; } +.encrypted-items__head { background: var(--ps-bg-elevated); font-size: var(--ps-text-sm); } +.encrypted-items__url { display: block; font-size: var(--ps-text-sm); color: var(--ps-text-muted); } +.encrypted-items__mobile-label { display: none; } +.encrypted-items__form { position: relative; display: grid; gap: var(--ps-space-3, .75rem); min-width: 0; } +.encrypted-items__form .ps-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--ps-space-4, 1rem); } +.encrypted-items__form .ps-label--full { grid-column: 1 / -1; } +.encrypted-items__form .ps-label, .encrypted-items__form .ps-input { min-width: 0; max-width: 100%; } +/* Keep ItemForm's submission/validation (including Enter) and place both modes' + Save/Cancel together. Cancellation must remain available for frozen drafts. */ +.encrypted-items__form .ps-actions { display: none; } +.encrypted-items__footer { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--ps-space-3, .75rem); } +.encrypted-items__eye { justify-self: end; } +.encrypted-items__form .ps-card { min-width: 0; } +.encrypted-items__list > p { padding: var(--ps-space-3, .75rem); } +.encrypted-attachments { min-width: 0; line-break: strict; word-break: normal; } +.encrypted-attachments ul { list-style: none; padding: 0; margin: 0 0 .75rem; } +.encrypted-attachments__row { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; border: 1px solid var(--ps-border); border-radius: var(--ps-radius-md); padding: .75rem; } +.encrypted-attachments__name { display: grid; gap: .25rem; flex: 1 1 12rem; min-width: 0; overflow-wrap: anywhere; } +.encrypted-attachments__name small { color: var(--ps-text-muted); } +.encrypted-attachments__actions { display: flex; flex-wrap: wrap; gap: .5rem; min-width: 0; } +.encrypted-attachments__heading:focus { outline: 2px solid var(--ps-border); outline-offset: 4px; } +.encrypted-attachments p { overflow-wrap: anywhere; } +@media (max-width: 680px) { + .encrypted-items__head { display: none; } + .encrypted-items__row { grid-template-columns: minmax(0, 1fr); } + .encrypted-items__mobile-label { display: inline; } + .encrypted-items__form .ps-grid, .encrypted-items__footer { grid-template-columns: minmax(0, 1fr); } + .encrypted-items__form .ps-card { padding: var(--ps-space-3, .75rem); } + .encrypted-items__actions .ps-button { flex: 1 1 0; } +} diff --git a/shared/cloud/session-transport.ts b/shared/cloud/session-transport.ts new file mode 100644 index 00000000..2076ac2c --- /dev/null +++ b/shared/cloud/session-transport.ts @@ -0,0 +1,52 @@ +import { createCloudApi, CloudApiError } from "./cloud-api"; +import { createSharingApi } from "./sharing-api"; + +export type CloudFetch = (path: string, init?: RequestInit) => Promise<Response>; +export type VaultMember = { userId: string; email: string; role: "owner" | "member"; joinedAt: string; identityConfirmed?: boolean }; + +// One immutable account/transport binding per authenticated renderer lifetime. +// Only the existing formal session token enters this closure, never a Connect token. +export function createCloudSession(base: string, userId: string, sessionToken: string, isCurrent: () => boolean = () => true) { + const origin = new URL(base); + if (origin.username || origin.password || origin.search || origin.hash || origin.pathname !== "/" || + (origin.protocol !== "https:" && !(origin.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(origin.hostname)))) throw new CloudApiError(0); + let alive = true; + const requests = new Set<AbortController>(); + const current = () => alive && isCurrent(); + const transport: CloudFetch = async (path, init = {}) => { + if (!current()) throw new CloudApiError(401); + const url = new URL(path, origin); + if (!path.startsWith("/api/") || url.origin !== origin.origin) throw new CloudApiError(0); + const controller = new AbortController(); + const abort = () => controller.abort(); + init.signal?.throwIfAborted(); + init.signal?.addEventListener("abort", abort, { once: true }); + requests.add(controller); + try { + const headers = new Headers(init.headers); + headers.set("Authorization", `Bearer ${sessionToken}`); + if (init.body !== undefined && init.body !== null) headers.set("Content-Type", "application/json"); + const response = await fetch(url, { ...init, headers, signal: controller.signal, credentials: "omit", cache: "no-store", redirect: "error" }); + if (!current() || controller.signal.aborted) { + await response.body?.cancel().catch(() => {}); + throw new CloudApiError(401); + } + return response; + } finally { + requests.delete(controller); + init.signal?.removeEventListener("abort", abort); + } + }; + return Object.freeze({ + userId, api: createCloudApi(transport), sharing: createSharingApi(transport), transport, + async listVaultMembers(boxId: string, signal: AbortSignal): Promise<VaultMember[]> { + const response = await transport(`/api/auth/vaults/${encodeURIComponent(boxId)}/members`, { signal }); + if (response.status !== 200) throw new CloudApiError(response.status); + const body = await response.json() as { members: VaultMember[] }; + if (!Array.isArray(body.members)) throw new CloudApiError(502); + return body.members; + }, + dispose() { alive = false; sessionToken = ""; for (const request of requests) request.abort(); requests.clear(); }, + }); +} +export type CloudSession = ReturnType<typeof createCloudSession>; diff --git a/shared/cloud/sharing-api.ts b/shared/cloud/sharing-api.ts new file mode 100644 index 00000000..e5bbe93c --- /dev/null +++ b/shared/cloud/sharing-api.ts @@ -0,0 +1,160 @@ +import type { CloudFetch } from "./session-transport"; +import { CloudApiError } from "./cloud-api"; +import type { PublicIdentity, SharedContext } from "./cloud-worker"; +export type IdentityRecord = { bundleVersion: number; identity: PublicIdentity; bundle: unknown }; +export type Member = { accountId: string; role: string; membershipConfirmed: boolean; keyGranted: boolean }; +export type Snapshot = { stateRevision: number; keyEpoch: number; ownerAccountId: string; ownerBundleVersion: number; + memberCount: number; liveItemCount: number; grantCount: number; liveAttachmentCount: number; liveAttachmentBytes: number }; + +export function createSharingApi(authFetch: CloudFetch) { + // Public routing, ciphertext and independently held public pins only. + // Cryptography, fingerprint comparison and membership policy run in Rust. + + const boxPath = (id: string) => `/api/encrypted/boxes/${encodeURIComponent(id)}`; + const identityPath = "/api/encrypted/account-identity"; + const exact = (value: unknown, keys: string[]): boolean => !!value && typeof value === "object" && !Array.isArray(value) && + Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); + const positive = (value: unknown): boolean => Number.isSafeInteger(value) && Number(value) > 0; + function publicText(value: unknown): string { + return JSON.stringify(value, (_key, entry: unknown) => entry && typeof entry === "object" && !Array.isArray(entry) + ? Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : entry); + } + async function request(url: string, signal: AbortSignal, body?: unknown): Promise<any> { + const response = await authFetch(url, { signal, cache: "no-store", redirect: "error", + ...(body === undefined ? {} : { method: "PUT", body: JSON.stringify(body) }) }); + if (response.status !== 200) throw new CloudApiError(response.status); + const reader = response.body?.getReader(); + if (!reader) throw new CloudApiError(502); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let size = 0; + let text = ""; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > 65536) throw new CloudApiError(502); + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); } + try { return JSON.parse(text); } catch { throw new CloudApiError(502); } + } + function record(value: IdentityRecord): IdentityRecord { + if (!exact(value, ["bundleVersion", "identity", "bundle"]) || !positive(value.bundleVersion) || + !exact(value.identity, ["accountId", "identityVersion", "publicKey", "keyId"]) || + !positive(value.identity.identityVersion) || [value.identity.accountId, value.identity.publicKey, value.identity.keyId] + .some((entry) => typeof entry !== "string") || publicText(value.bundle).length > 2048) throw new CloudApiError(502); + return value; + } + async function getIdentity(signal: AbortSignal): Promise<IdentityRecord | null> { + try { return record(await request(identityPath, signal)); } + catch (error) { if (error instanceof CloudApiError && error.status === 404) return null; throw error; } + } + async function createIdentity(prepared: IdentityRecord, signal: AbortSignal): Promise<IdentityRecord> { + record(prepared); + const reply = record(await request(identityPath, signal, { expectedVersion: 0, identity: prepared.identity, bundle: prepared.bundle })); + if (reply.bundleVersion !== 1 || publicText(reply) !== publicText(prepared)) throw new CloudApiError(409); + return reply; + } + async function getMemberIdentity(boxId: string, accountId: string, signal: AbortSignal): Promise<PublicIdentity> { + const reply = await request(`${boxPath(boxId)}/member-identities/${encodeURIComponent(accountId)}`, signal); + if (!exact(reply, ["identity"])) throw new CloudApiError(502); + const value = reply.identity; + if (!exact(value, ["accountId", "identityVersion", "publicKey", "keyId"]) || value.accountId !== accountId || + !positive(value.identityVersion) || typeof value.publicKey !== "string" || typeof value.keyId !== "string") throw new CloudApiError(502); + return value; + } + async function getSnapshot(boxId: string, signal: AbortSignal): Promise<Snapshot> { + const value = await request(`${boxPath(boxId)}/sharing-snapshot`, signal); + const keys = ["stateRevision", "keyEpoch", "ownerAccountId", "ownerBundleVersion", "memberCount", "liveItemCount", "grantCount"]; + // The official API omits both attachment counters only for an empty set. + // Keep strict shape validation, including rejection of a missing peer counter. + const hasAttachments = !!value && Object.hasOwn(value, "liveAttachmentCount"); + if (!exact(value, hasAttachments ? [...keys, "liveAttachmentCount", "liveAttachmentBytes"] : keys) || + ![value.stateRevision, value.keyEpoch, value.ownerBundleVersion, value.memberCount].every(positive) || + ![value.liveItemCount, value.grantCount].every((v) => Number.isSafeInteger(v) && v >= 0) || typeof value.ownerAccountId !== "string" || + (hasAttachments && (!positive(value.liveAttachmentCount) || value.liveAttachmentCount > 1024 || + !positive(value.liveAttachmentBytes) || value.liveAttachmentBytes > 1073741824))) throw new CloudApiError(502); + return { ...value, liveAttachmentCount: value.liveAttachmentCount ?? 0, liveAttachmentBytes: value.liveAttachmentBytes ?? 0 }; + } + async function getSnapshotMembers(boxId: string, snapshot: Snapshot, signal: AbortSignal): Promise<Member[]> { + const members: Member[] = []; + let after: string | null = null; + do { + const page = await request(`${boxPath(boxId)}/sharing-snapshot/members?revision=${snapshot.stateRevision}${after === null ? "" : `&after=${encodeURIComponent(after)}`}`, signal); + if (!exact(page, ["stateRevision", "members", "nextCursor"]) || page.stateRevision !== snapshot.stateRevision || + !Array.isArray(page.members) || page.members.length > 100) throw new CloudApiError(502); + let previous = after ?? ""; + for (const member of page.members) { + if (!exact(member, ["accountId", "role", "membershipConfirmed", "keyGranted"]) || + typeof member.accountId !== "string" || member.accountId <= previous || typeof member.role !== "string" || + typeof member.membershipConfirmed !== "boolean" || typeof member.keyGranted !== "boolean") throw new CloudApiError(502); + previous = member.accountId; + members.push(member); + } + if ((page.members.length === 100 ? page.nextCursor !== previous : page.nextCursor !== null) || + members.length > snapshot.memberCount || members.length > 1000) throw new CloudApiError(502); + after = page.nextCursor; + } while (after !== null); + if (members.length !== snapshot.memberCount) throw new CloudApiError(502); + return members; + } + async function putGrant(boxId: string, revision: number, context: SharedContext, envelope: unknown, signal: AbortSignal): Promise<void> { + const reply = await request(`${boxPath(boxId)}/grants/${encodeURIComponent(context.recipientAccountId)}`, signal, + { expectedStateRevision: revision, keyEpoch: context.keyEpoch, expectedVersion: 0, envelope }); + if (!exact(reply, ["grantVersion", "keyEpoch", "stateRevision", "envelope"]) || reply.grantVersion !== 1 || + reply.keyEpoch !== context.keyEpoch || !positive(reply.stateRevision) || reply.stateRevision < revision || + publicText(reply.envelope) !== publicText(envelope)) throw new CloudApiError(502); + } + async function getSelfGrant(boxId: string, context: SharedContext, signal: AbortSignal): Promise<string> { + const reply = await request(`${boxPath(boxId)}/grants/me`, signal); + if (!exact(reply, ["grantVersion", "keyEpoch", "envelope"]) || reply.grantVersion !== 1 || reply.keyEpoch !== context.keyEpoch || + !exact(reply.envelope, ["format", "suite", "context", "enc", "ciphertext"]) || + publicText(reply.envelope.context) !== publicText(context) || publicText(reply.envelope).length > 2048) throw new CloudApiError(502); + return JSON.stringify(reply.envelope); + } + + // Three separate, account-scoped stores. Neither bundle nor a server reply + // supplies an expected context/key ID. Pins contain public descriptors only. + const ownKey = (account: string) => `passshare.identity-own-pin.v1:${encodeURIComponent(account)}`; + const peerKey = (account: string, peer: string) => `passshare.identity-peer-pin.v1:${encodeURIComponent(account)}:${encodeURIComponent(peer)}`; + const pendingKey = (account: string) => `passshare.identity-pending.v1:${encodeURIComponent(account)}`; + function read(key: string): unknown | null { + try { const text = localStorage.getItem(key); return text === null ? null : JSON.parse(text); } + catch { throw new CloudApiError(409); } + } + function write(key: string, value: unknown) { + const text = publicText(value); + try { localStorage.setItem(key, text); if (localStorage.getItem(key) !== text) throw new CloudApiError(409); } + catch { throw new CloudApiError(409); } + } + function readOwnPin(account: string): PublicIdentity | null { return read(ownKey(account)) as PublicIdentity | null; } + // Call only with the pin returned by Rust identityPrepare after private-key + // restoration and an independently entered code (or the existing own pin). + function savePreparedOwnPin(account: string, identity: PublicIdentity) { + if (identity.accountId !== account) throw new CloudApiError(409); + write(ownKey(account), identity); + } + function readPeerPin(account: string, peer: string): PublicIdentity | null { return read(peerKey(account, peer)) as PublicIdentity | null; } + function savePeerPin(account: string, peer: string, identity: PublicIdentity) { + if (identity.accountId !== peer) throw new CloudApiError(409); + write(peerKey(account, peer), identity); + } + function readPendingIdentity(account: string): IdentityRecord | null { + const value = read(pendingKey(account)); return value === null ? null : record(value as IdentityRecord); + } + function savePendingIdentity(account: string, value: IdentityRecord) { + const previous = readPendingIdentity(account); + if (value.identity.accountId !== account || (previous && publicText(previous) !== publicText(value))) throw new CloudApiError(409); + write(pendingKey(account), record(value)); + } + function clearPendingIdentity(account: string, expected: IdentityRecord) { + const value = readPendingIdentity(account); + if (value && publicText(value) !== publicText(expected)) throw new CloudApiError(409); + localStorage.removeItem(pendingKey(account)); + } + + return Object.freeze({ publicText, getIdentity, createIdentity, getMemberIdentity, getSnapshot, getSnapshotMembers, putGrant, getSelfGrant, readOwnPin, savePreparedOwnPin, readPeerPin, savePeerPin, readPendingIdentity, savePendingIdentity, clearPendingIdentity }); +} +export type SharingApi = ReturnType<typeof createSharingApi>; diff --git a/shared/cloud/useItemLabels.ts b/shared/cloud/useItemLabels.ts new file mode 100644 index 00000000..11730beb --- /dev/null +++ b/shared/cloud/useItemLabels.ts @@ -0,0 +1,52 @@ +import { useMemo } from "react"; +import { useI18n } from "@shared/i18n/I18nProvider"; +import type { ItemUiLabels } from "@shared/types/item.js"; + +export function useItemLabels(): ItemUiLabels { + const { t } = useI18n(); + + return useMemo( + () => ({ + categoryLogin: t("item.category.login"), + categoryPassword: t("item.category.password"), + categorySecureNote: t("item.category.secureNote"), + categoryCreditCard: t("item.category.creditCard"), + categoryIdentity: t("item.category.identity"), + categorySshKey: t("item.category.sshKey"), + categoryCustom: t("item.category.custom"), + fieldTitle: t("item.field.title"), + fieldUrl: t("item.field.url"), + fieldUsername: t("item.field.username"), + fieldPassword: t("item.field.password"), + fieldBody: t("item.field.body"), + fieldNotes: t("item.field.notes"), + fieldTags: t("item.field.tags"), + fieldFavorite: t("item.field.favorite"), + addHeading: t("app.item.addHeading"), + editHeading: t("app.item.editHeading"), + listHeading: t("app.item.listHeading"), + searchPlaceholder: t("app.item.searchPlaceholder"), + generatePassword: t("app.item.generatePassword"), + submitAdd: t("app.item.submitAdd"), + submitSave: t("app.item.submitSave"), + cancelEdit: t("app.item.cancelEdit"), + copyPassword: t("app.item.copyPassword"), + copyBody: t("app.item.copyBody"), + copied: t("app.item.copied"), + delete: t("app.item.delete"), + edit: t("app.item.edit"), + importCsv: t("app.item.importCsv"), + import1pux: t("app.item.import1pux"), + exportCsv: t("app.item.exportCsv"), + importSuccess: t("app.item.importSuccess"), + import1puxSuccess: t("app.item.import1puxSuccess"), + exportSuccess: t("app.item.exportSuccess"), + emptyList: t("app.item.emptyList"), + lock: t("app.item.lock"), + countSuffix: t("app.item.countSuffix"), + confirmDelete: t("app.item.confirmDelete"), + notSet: t("app.item.notSet"), + }), + [t], + ); +} diff --git a/shared/lib/1pux-import.ts b/shared/lib/1pux-import.ts index 24203163..fd3dbadf 100644 --- a/shared/lib/1pux-import.ts +++ b/shared/lib/1pux-import.ts @@ -51,6 +51,13 @@ export async function import1puxWithSession( throw new Error("Login failed"); } + const modeResponse = await fetch(`${apiBaseUrl}/api/encrypted/boxes/${encodeURIComponent(vaultId)}`, { + credentials: "include", cache: "no-store", redirect: "error", + }); + if (!modeResponse.ok) throw new Error("Password box mode unavailable"); + const mode = await modeResponse.json() as { mode: string; keyEpoch: number | null }; + if (mode.mode !== "compatibility" || mode.keyEpoch !== null) throw new Error("Encrypted password box requires encrypted migration"); + const tokenRes = await fetch(`${apiBaseUrl}/api/auth/connect-token`, { credentials: "include", }); diff --git a/shared/vite/cloud-assets.ts b/shared/vite/cloud-assets.ts new file mode 100644 index 00000000..e28c5b14 --- /dev/null +++ b/shared/vite/cloud-assets.ts @@ -0,0 +1,43 @@ +import path from "node:path"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import type { Plugin } from "vite"; + +// Every renderer packages independent copies of the same CI-tested Rust assets. +// This copies verified bytes; it never regenerates or edits the promoted assets. +export function cloudAssets(repository: string): Plugin { + const folder = path.join(repository, "frontend/public/cloud-crypto"); + const manifest = JSON.parse(readFileSync(path.join(repository, "frontend/cloud-assets.json"), "utf8")) as { + buildKind: string; artifact: string; inputs: Record<string, string>; assets: Record<string, string>; + }; + const assets = new Map<string, Buffer>(); + function verify() { + if (manifest.buildKind !== "github-actions-tested" || manifest.artifact !== "passshare-cloud-wasm") throw new Error("Unverified cloud WASM provenance"); + for (const [file, expected] of Object.entries(manifest.inputs)) { + const text = readFileSync(path.join(repository, file), "utf8").replace(/\r\n/g, "\n"); + if (createHash("sha256").update(text).digest("hex") !== expected) throw new Error(`Cloud WASM source changed: ${file}`); + } + for (const [file, expected] of Object.entries(manifest.assets)) { + if (!["boot.mjs", "passshare_cloud_wasm.js", "passshare_cloud_wasm_bg.wasm"].includes(file)) throw new Error("Unexpected cloud asset"); + const bytes = readFileSync(path.join(folder, file)); + if (createHash("sha256").update(bytes).digest("hex") !== expected) throw new Error(`Cloud WASM asset changed: ${file}`); + assets.set(file, bytes); + } + if (assets.size !== 3) throw new Error("Missing cloud WASM assets"); + } + return { + name: "tested-cloud-assets", + buildStart: verify, + generateBundle() { for (const [file, source] of assets) this.emitFile({ type: "asset", fileName: `cloud-crypto/${file}`, source }); }, + configureServer(server) { + verify(); + server.middlewares.use((request, response, next) => { + const file = request.url?.split("?")[0]?.replace(/^\/cloud-crypto\//, ""); + const bytes = file && assets.get(file); + if (!bytes) { next(); return; } + response.setHeader("Content-Type", file!.endsWith(".wasm") ? "application/wasm" : "text/javascript"); + response.end(bytes); + }); + }, + }; +} From aafa5a6615872006cd0dbbc8c49c1be0b10f8550 Mon Sep 17 00:00:00 2001 From: mashfromband <matsumoto.yukuhashi@gmail.com> Date: Sun, 11 Oct 2026 19:09:06 +0900 Subject: [PATCH 07/10] fix(ui): retain session ownership through reads and account transitions --- .../src/components/CloudItemsPanel.tsx | 4 +- apps/desktop/src/lib/cloud-sync.ts | 24 ++-- apps/mobile/src/components/ItemsPanel.tsx | 9 +- .../spec/cross-client-encrypted-items-ui.md | 13 +- frontend/tests/attachments-ui.test.mjs | 1 + frontend/tests/cross-client-cloud.test.mjs | 133 +++++++++++++++++- .../src/background/service-worker.ts | 29 ++-- packages/extension/src/lib/session-persist.ts | 7 +- packages/extension/src/lib/storage.ts | 51 ++++++- .../src/popup/components/ItemsPanel.tsx | 9 +- .../src/popup/hooks/useAuthSession.tsx | 57 ++++---- shared/cloud/CloudBoxPanel.tsx | 21 ++- shared/cloud/box-mode.ts | 18 +++ shared/cloud/session-transport.ts | 39 ++++- shared/cloud/sharing-api.ts | 2 +- shared/lib/1pux-import.ts | 16 ++- 16 files changed, 345 insertions(+), 88 deletions(-) create mode 100644 shared/cloud/box-mode.ts diff --git a/apps/desktop/src/components/CloudItemsPanel.tsx b/apps/desktop/src/components/CloudItemsPanel.tsx index 03b44918..03fcb362 100644 --- a/apps/desktop/src/components/CloudItemsPanel.tsx +++ b/apps/desktop/src/components/CloudItemsPanel.tsx @@ -63,11 +63,13 @@ export function CloudItemsPanel() { const auth = await response.json() as { user: { id: string }; refreshToken: string; connectAccessToken: string }; if (!current()) return; next = createCloudSession(url.href, auth.user.id, auth.refreshToken, current); + binding.current = next; const client = new ConnectClient({ host: url.origin, token: auth.connectAccessToken }); + auth.refreshToken = ""; auth.connectAccessToken = ""; const list = await client.listVaults(); if (!current()) { next.dispose(); return; } binding.current = next; setSession(next); setBoxes(list); setBoxId(list[0]?.id ?? ""); - } catch { next?.dispose(); if (current()) setFailed(true); } + } catch { next?.dispose(); if (binding.current === next) binding.current = null; if (current()) setFailed(true); } finally { body = ""; if (current()) { request.current = null; setBusy(false); } } } async function logout() { diff --git a/apps/desktop/src/lib/cloud-sync.ts b/apps/desktop/src/lib/cloud-sync.ts index 12c53357..e1f36215 100644 --- a/apps/desktop/src/lib/cloud-sync.ts +++ b/apps/desktop/src/lib/cloud-sync.ts @@ -138,25 +138,25 @@ async function loginAndGetClient(config: CloudSyncConfig): Promise<ConnectClient }); assertOk(loginRes, "Login failed"); - // This legacy sync transfers plaintext. Only authoritative compatibility - // state permits it; unknown/encrypted/disabled state never reaches Connect. - const stateRes = await syncFetch(`${config.apiBaseUrl}/api/encrypted/boxes/${encodeURIComponent(config.vaultId)}`, { - credentials: "include", cache: "no-store", redirect: "error", - }); - assertOk(stateRes, "Password box mode unavailable"); - const state = await stateRes.json() as { mode: string; keyEpoch: number | null }; - if (state.mode !== "compatibility" || state.keyEpoch !== null) throw new SyncError("unknown", "Encrypted password box requires encrypted item screen"); - const tokenRes = await syncFetch(`${config.apiBaseUrl}/api/auth/connect-token`, { credentials: "include", }); assertOk(tokenRes, "Connect token fetch failed"); const tokenBody = (await tokenRes.json()) as { accessToken: string }; - return new ConnectClient({ - host: config.apiBaseUrl, - token: tokenBody.accessToken, + const client = new ConnectClient({ host: config.apiBaseUrl, token: tokenBody.accessToken }); + const stateRes = await syncFetch(`${config.apiBaseUrl}/api/encrypted/boxes/${encodeURIComponent(config.vaultId)}`, { + credentials: "include", cache: "no-store", redirect: "error", }); + if (stateRes.status === 404) { + // Metadata only: the existing DB plaintext boundary rejects encrypted boxes. + await client.getVault(config.vaultId); + } else { + assertOk(stateRes, "Password box mode unavailable"); + const state = await stateRes.json() as { mode: string; keyEpoch: number | null }; + if (state.mode !== "compatibility" || state.keyEpoch !== null) throw new SyncError("unknown", "Encrypted password box requires encrypted item screen"); + } + return client; } async function fetchRemoteItems(client: ConnectClient, vaultId: string): Promise<ConnectItem[]> { diff --git a/apps/mobile/src/components/ItemsPanel.tsx b/apps/mobile/src/components/ItemsPanel.tsx index 75c22fbd..207b62e0 100644 --- a/apps/mobile/src/components/ItemsPanel.tsx +++ b/apps/mobile/src/components/ItemsPanel.tsx @@ -17,11 +17,16 @@ type Props = { }; export function ItemsPanel(props: Props) { - const { cloudSession } = useAuthSession(); + const { cloudSession, connectClient } = useAuthSession(); + const metadataProof = useCallback(async (id: string, signal: AbortSignal) => { + signal.throwIfAborted(); + if (!connectClient) throw new Error("Session unavailable"); + await connectClient.getVault(id); signal.throwIfAborted(); + }, [connectClient]); const { t } = useI18n(); if (!cloudSession) return <p role="status">{t("session.loading")}</p>; return <><button type="button" className="ps-button ps-button--secondary" onClick={props.onBack}>{t("app.vault.back")}</button> - <CloudBoxPanel key={`${cloudSession.userId}:${props.vaultId}`} session={cloudSession} boxId={props.vaultId} + <CloudBoxPanel key={`${cloudSession.userId}:${props.vaultId}`} session={cloudSession} boxId={props.vaultId} metadataProof={metadataProof} compatibility={(ensureCompatibility) => <CompatibilityItemsPanel {...props} ensureCompatibility={ensureCompatibility} />} /></>; } function CompatibilityItemsPanel({ vaultId, onBack, ensureCompatibility }: Props & { ensureCompatibility: () => Promise<void> }) { diff --git a/documents/spec/cross-client-encrypted-items-ui.md b/documents/spec/cross-client-encrypted-items-ui.md index 5530f952..9d334c63 100644 --- a/documents/spec/cross-client-encrypted-items-ui.md +++ b/documents/spec/cross-client-encrypted-items-ui.md @@ -26,6 +26,8 @@ Desktopは広い画面で一覧と編集を並べ、Mobileと拡張は1列にす 拡張は既存正式session復元を使う。Desktopは既存token-sessionログイン応答を使い、メモリだけで保持する。 - 不明・失敗・暗号化モードは互換項目画面をマウントしない。 互換の表示・変更直前にも正式のモードを確認する。401/403/409は失敗で、平文へ戻さない。 + フラグ無効時の404は既存Webと同じく、正式Connectのmetadataが現在のDB境界で成功する場合だけ互換とする。 + 既に暗号化を観測した画面ではこの経路を使わない。項目本文は確認に使わない。 - 所有者の期待contextはセッションの本人・選択したbox・保持したepochから作る。 所有者bundle/envelopeから期待値を作らない。共有受信者は既存Rustのcontext選択と独立した本人/相手のpinを使う。 帯域外コード入力・比較を省略せず、公開stateやgrantを信頼の根拠にしない。 @@ -34,6 +36,10 @@ Desktopは広い画面で一覧と編集を並べ、Mobileと拡張は1列にす atomic CAS、409下書き保持、応答不明時の凍結と同じopaque要求の明示再送は共通Runtimeを使う。 - 言語・一覧選択では同じ下書きを維持する。別の本人/box/epoch、ログアウト、背景、ページ終了、 popup閉鎖、5分アイドルでworker・鍵・下書き・バッファ・object URLを破棄し、遅い応答を表示しない。 + HTTPの所有管理は本文EOF/取消まで継続し、呼出元のabortとdisposeを本文にも伝える。 + 本文の通信故障を401に読み替えず、応答不明の下書きを保つ。正常EOFでも中断リスナーを解除する。 + 拡張の正式sessionは一括読取り/書込みと同originのWeb Locksで順序を維持し、遅い保存は自身の資格だけを消す。 + 古いrefresh/logoutは保持したsessionと照合し、別アカウントの保存を上書き・削除しない。 秘密鍵をstorage/DB/ログへ書かない。JS文字列のメモリ全域の物理消去は保証しない。 Desktopの`.pbox`、緊急キット復旧、保存失敗保持は既存経路を保つ。 @@ -64,7 +70,7 @@ TauriとMV3のscript-srcには `'self' 'wasm-unsafe-eval'`、worker-srcには `' 2026-10-11に、Web・3クライアントのViteビルド、型検査、10言語481キーが成功。 3クライアント×3個の同梱アセットすべてで正式manifestとSHA256が一致。 -共通項目・添付25件、新しいsession/画面寿命・背景探索15件、既存Desktop52件・Mobile18件・拡張41件が成功。 +共通項目・添付25件、新しいsession/画面寿命・背景探索26件、既存Desktop52件・Mobile18件・拡張41件が成功。 新しい検査は別セッションの同時通信、epoch変更後の遅い応答、dispose/abort、実HTTP redirect非追従、 401/403/409/404、互換からの暗号化切替、解除中のlock/背景/unmount、受信者の独立pin経路を扱う。 項目・添付の検査は全名前・空ファイル・259byteの全bytes、409/応答喪失、取消・遅いFile読取りを扱う。 @@ -80,7 +86,10 @@ WASM関連nextest43件(省略0)、all-targets Clippy、fmtが成功。 CUAでのタブ取得は37秒で時間切れとなった。画面配置・320pxやnativeの成功とは扱わない。 自作タブ798082180は工程終了時にclosed1/gone0/failed0で閉じ、inventoryで不在を確認した。 -別モデルの固定差分レビューは続けて行う。 +最初の固定01b0f30のGPT-5.5独立レビューはMust/Suggestion/Nit 0。 +追加の点検で本文処理の所有寿命と拡張の遅い保存を補強し、対応する取消/EOF/保存競合の検査を追加した。 +フラグ無効環境の互換経路は既存Webのmetadata確認と同じ条件へ合わせ、添付の全名前も直接照合した。 +追加差分も独立レビューする。 最新の統合ソースに対するroot全workspace/doctest、全CI、正式Node/PGとの3クライアント往復、 MV3実popupのCSPと320px、Tauri/native WebViewの実保存/背景/再起動、全クライアントの復旧・失効・移行は親が正式に確認する。 親C1 fixtureと親PGは操作していない。新DB/アカウントは作成していない。 diff --git a/frontend/tests/attachments-ui.test.mjs b/frontend/tests/attachments-ui.test.mjs index fa7ba711..77798967 100644 --- a/frontend/tests/attachments-ui.test.mjs +++ b/frontend/tests/attachments-ui.test.mjs @@ -145,6 +145,7 @@ test('new item plus arbitrary binary/empty/259-byte files closes only after all h.form().props.onSubmit({ ...values }); await flush(); assert.equal(h.calls.create, 1); assert.equal(h.calls.attachment.length, 4); assert.equal(h.form(), undefined); assert.deepEqual(h.calls.encrypt.map((r) => r.bytes.length), [2, 0, 259, 1]); + assert.deepEqual(h.calls.encrypt.map((r) => r.name), ['日本語.bin', '空', 'same', 'same']); assert.deepEqual(h.calls.encrypt[2].bytes, Array.from({ length: 259 }, (_, i) => i % 256)); assert.deepEqual(h.calls.encrypt[0].bytes, [0, 255]); assert.equal(h.fileInput.value, ''); }); diff --git a/frontend/tests/cross-client-cloud.test.mjs b/frontend/tests/cross-client-cloud.test.mjs index d4381a1a..9af19447 100644 --- a/frontend/tests/cross-client-cloud.test.mjs +++ b/frontend/tests/cross-client-cloud.test.mjs @@ -18,11 +18,12 @@ function load(name, imports = {}, globals = {}) { if (!(id in imports)) throw new Error(`Unmapped import ${id}`); return imports[id]; }, URL, Headers, Response, AbortController, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, - setTimeout, clearTimeout, atob, btoa, ...globals }); + ReadableStream, setTimeout, clearTimeout, atob, btoa, ...globals }); return exports; } const cloudApi = load('cloud-api.ts'); const sharingApi = load('sharing-api.ts', { './cloud-api': cloudApi }); +const boxMode = load('box-mode.ts', { './cloud-api': cloudApi }); const sessionModule = (fetch) => load('session-transport.ts', { './cloud-api': cloudApi, './sharing-api': sharingApi }, { fetch }); const box = 'a'.repeat(26), owner = '11111111-1111-4111-8111-111111111111'; const flush = () => renderer.act(async () => { for (let n = 0; n < 10; n++) await new Promise(setImmediate); }); @@ -35,14 +36,14 @@ function events() { function host({ mode = 'encrypted', failure = null, delayedUnlock = false, recipient = false } = {}) { const window = events(), document = { ...events(), visibilityState: 'visible' }; const input = { value: 'synthetic-encryption-password' }, workers = [], calls = [], legacy = [], unlocks = []; - let release, tree; + let release, tree, epoch = 17; class Worker { constructor() { this.isClosed = false; this.closed = new Promise((resolve) => { this.close = resolve; }); this.ready = Promise.resolve(); workers.push(this); } dispose() { this.isClosed = true; this.close(); } async unlock(scope, bundle, secret) { unlocks.push({ scope, bundle, secret }); if (delayedUnlock) await new Promise((resolve) => { release = resolve; }); } } const session = { userId: owner, api: { - async getBoxState(id, signal) { calls.push({ id, signal }); if (failure) throw new cloudApi.CloudApiError(failure); return { mode, keyEpoch: mode === 'encrypted' ? 17 : null }; }, + async getBoxState(id, signal) { calls.push({ id, signal }); if (failure) throw new cloudApi.CloudApiError(failure); return { mode, keyEpoch: mode === 'encrypted' ? epoch : null }; }, getOwnerBundle: async () => ({ bundle: 'opaque-owner-bundle' }), bundleText: (text) => text, }, listVaultMembers: async () => [{ userId: owner, role: recipient ? 'member' : 'owner', identityConfirmed: true }], sharing: {} }; function Items() { return React.createElement('output', null, 'encrypted-items'); } @@ -50,12 +51,15 @@ function host({ mode = 'encrypted', failure = null, delayedUnlock = false, recip const { CloudBoxPanel } = load('CloudBoxPanel.tsx', { react: React, 'react/jsx-runtime': requireApp('react/jsx-runtime'), '@shared/i18n/I18nProvider': { useI18n: () => ({ t: (key) => key }) }, './cloud-api': cloudApi, './cloud-worker': { CloudWorker: Worker }, './EncryptedItems': { EncryptedItems: Items }, + './box-mode': boxMode, './CloudSharingPanel': { SharedCloudSharingPanel: Sharing }, './client-cloud.css': {}, }, { window, document }); const compatibility = (ensure) => { legacy.push(ensure); return React.createElement('output', null, 'compatibility-items'); }; renderer.act(() => { tree = renderer.create(React.createElement(CloudBoxPanel, { session, boxId: box, compatibility }), { createNodeMock: () => input }); }); return { tree, input, workers, window, document, legacy, unlocks, calls, session, setMode(value) { mode = value; }, release: () => release?.(), + setEpoch(value) { epoch = value; }, setFailure(value) { failure = value; }, + async recheck() { renderer.act(() => tree.update(React.createElement(CloudBoxPanel, { session, boxId: box, compatibility, metadataProof: async () => {} }))); await flush(); }, async submit() { renderer.act(() => tree.root.findByType('form').props.onSubmit({ preventDefault() {} })); await flush(); }, async lock() { renderer.act(() => window.dispatchEvent({ type: 'passshare:cloud-lock' })); await flush(); }, async unmount() { renderer.act(() => tree.unmount()); await flush(); }, @@ -94,6 +98,49 @@ test('dispose aborts a held request and rejects later requests before network ac assert.equal(sends, 1); }); +for (const end of ['dispose', 'caller-abort']) test(`session owns a delayed response body through ${end}`, async () => { + let source, cancelled = false; + const { createCloudSession } = sessionModule(async () => new Response(new ReadableStream({ + start(controller) { source = controller; }, cancel() { cancelled = true; }, + }))); + const session = createCloudSession('https://example.invalid', owner, 'synthetic-session'); + const caller = new AbortController(); + const pending = session.api.getBoxState(box, caller.signal); + await new Promise(setImmediate); + if (end === 'dispose') session.dispose(); else caller.abort(); + if (!cancelled) { source.enqueue(new TextEncoder().encode('{"mode":"encrypted","keyEpoch":17}')); source.close(); } + await assert.rejects(pending); + assert.equal(cancelled, true); session.dispose(); +}); + +test('response EOF releases upstream abort listener without requiring caller abort', async () => { + const caller = new AbortController(), attached = new Set(); + const add = caller.signal.addEventListener.bind(caller.signal), remove = caller.signal.removeEventListener.bind(caller.signal); + caller.signal.addEventListener = (name, fn, options) => { attached.add(fn); add(name, fn, options); }; + caller.signal.removeEventListener = (name, fn, options) => { attached.delete(fn); remove(name, fn, options); }; + const { createCloudSession } = sessionModule(async () => new Response('{"mode":"encrypted","keyEpoch":17}')); + const session = createCloudSession('https://example.invalid', owner, 'synthetic-session'); + await session.api.getBoxState(box, caller.signal); + assert.equal(caller.signal.aborted, false); assert.equal(attached.size, 0); session.dispose(); +}); + +test('a broken successful response body remains an unknown outcome, not an authorization failure', async () => { + const { createCloudSession } = sessionModule(async () => new Response(new ReadableStream({ + start(controller) { controller.error(new TypeError('synthetic connection reset')); }, + }))); + const session = createCloudSession('https://example.invalid', owner, 'synthetic-session'); + const response = await session.transport('/api/encrypted/account-identity', { method: 'PUT', body: '{"opaque":"synthetic"}' }); + await assert.rejects(response.text(), (error) => error.status === 0); session.dispose(); +}); + +test('authorization rejection cancels the unread response body rather than retaining its reader', async () => { + let cancelled = 0; + const { createCloudSession } = sessionModule(async () => new Response(new ReadableStream({ cancel() { cancelled++; } }), { status: 403 })); + const session = createCloudSession('https://example.invalid', owner, 'synthetic-session'); + await assert.rejects(session.sharing.getIdentity(new AbortController().signal), (error) => error.status === 403); + assert.equal(cancelled, 1); session.dispose(); assert.equal(cancelled, 1); +}); + test('real redirect response is not followed and cannot transmit the session bearer to its destination', async () => { let direct = 0, redirected = 0; const server = http.createServer((request, response) => { @@ -139,6 +186,14 @@ test('recipient enters independent identity/pin flow without an owner password o assert.equal(h.unlocks.length, 0); assert.equal(h.legacy.length, 0); await h.unmount(); }); +for (const change of ['epoch', 'authorization']) test(`same-account metadata refresh after ${change} destroys the held worker and decrypted screen`, async () => { + const h = host(); await flush(); await h.submit(); + assert.equal(h.json().includes('"encrypted-items"'), true); + if (change === 'epoch') h.setEpoch(18); else h.setFailure(403); + await h.recheck(); assert.equal(h.workers[0].isClosed, true); + assert.equal(h.json().includes('"encrypted-items"'), false); await h.unmount(); +}); + function backgroundHost() { let listener, mode = 'compatibility', account = owner, disposed = 0, details = 0, writes = 0; const chrome = { runtime: { onMessage: { addListener(fn) { listener = fn; } } }, alarms: { onAlarm: { addListener() {} } } }; @@ -155,9 +210,10 @@ function backgroundHost() { './clipboard-clear': { handleClipboardClearAlarm() {}, scheduleClipboardClear() {} }, '../lib/connect-items': { connectItemFromApi: (item) => item, connectItemToCreateBody: (body) => body, hostnameMatchesItem: () => true }, '../lib/password-generator': { generatePassword: () => 'synthetic-generated' }, - '../lib/storage': { clearAllAuthStorage() {}, loadRefreshToken: async () => 'synthetic-session', loadStoredConnectToken: async () => ({ token: 'synthetic-connect' }), saveConnectToken() {} }, + '../lib/storage': { clearAllAuthStorage() {}, loadRefreshToken: async () => 'synthetic-session', loadStoredAuthSession: async () => ({ refreshToken: 'synthetic-session', connectAccessToken: 'synthetic-connect' }), saveConnectToken() {} }, '@shared/lib/connect-session': { async bootstrapConnectSession(storage) { await storage.load(); return { user: { id: account }, accessToken: 'synthetic-connect' }; } }, '@shared/cloud/session-transport': { createCloudSession: () => ({ userId: account, api: { getBoxState: async () => ({ mode }) }, dispose() { disposed++; } }) }, + '@shared/cloud/box-mode': boxMode, }, { chrome }); return { send: (message) => new Promise((resolve) => listener(message, {}, resolve)), setMode(value) { mode = value; }, setAccount(value) { account = value; }, @@ -185,3 +241,72 @@ test('background fill and page save recheck current mode and cannot consume a pr h.setAccount(owner); assert.equal((await h.send(request)).ok, true); assert.equal(h.details, 2); assert.equal(h.disposed, 5); }); + +function storedSessionHost() { + const keys = { refreshToken: 'refresh', connectToken: 'connect', connectExpiresAt: 'expiry' }, values = new Map(); + let queue = Promise.resolve(), held = false, release, reads = 0; + const navigator = { locks: { request(_name, action) { const result = queue.then(action); queue = result.catch(() => {}); return result; } } }; + const chrome = { runtime: {}, storage: { local: { + get(names, done) { reads++; queueMicrotask(() => done(Object.fromEntries(names.map((key) => [key, values.get(key)])))); }, + set(entries, done) { const write = () => { for (const [key, value] of Object.entries(entries)) values.set(key, value); done(); }; + if (held) { held = false; release = write; } else queueMicrotask(write); }, + remove(names, done) { for (const key of names) values.delete(key); queueMicrotask(done); }, + } } }; + const api = load('../../packages/extension/src/lib/storage.ts', { './constants': { STORAGE_KEYS: keys } }, { navigator, chrome }); + return { api, values, holdNext() { held = true; }, release: () => release(), get reads() { return reads; } }; +} + +const authA = { refreshToken: 'session-A', connectAccessToken: 'connect-A', connectExpiresAt: '2040-01-01T00:00:00Z' }; +const authB = { refreshToken: 'session-B', connectAccessToken: 'connect-B', connectExpiresAt: '2041-01-01T00:00:00Z' }; + +test('a late popup session write is erased before a new account may persist its complete session', async () => { + const h = storedSessionHost(); let current = true; + h.holdNext(); const old = h.api.saveStoredAuthSession(authA, () => current); + await new Promise(setImmediate); current = false; + const next = h.api.saveStoredAuthSession(authB); h.release(); + assert.equal(await old, false); assert.equal(await next, true); + assert.deepEqual(JSON.parse(JSON.stringify(await h.api.loadStoredAuthSession())), authB); + assert.equal([...h.values.values()].some((value) => value.includes('session-A') || value.includes('connect-A')), false); +}); + +test('old refresh/logout storage callbacks cannot replace or erase a newer account', async () => { + const h = storedSessionHost(); await h.api.saveStoredAuthSession(authB); + await h.api.saveConnectToken('stale-connect-A', authA.connectExpiresAt, authA.refreshToken); + await h.api.clearAllAuthStorage(authA.refreshToken); + const before = h.reads; + assert.deepEqual(JSON.parse(JSON.stringify(await h.api.loadStoredAuthSession())), authB); + assert.equal(h.reads - before, 1); + await h.api.clearAllAuthStorage(authB.refreshToken); assert.equal(await h.api.loadStoredAuthSession(), null); +}); + +test('flag-off compatibility needs authoritative metadata and is forbidden after observing encrypted mode', async () => { + let proofs = 0, denied = false; + const api = { getBoxState: async () => { throw new cloudApi.CloudApiError(404); } }; + const proof = async () => { proofs++; if (denied) throw new cloudApi.CloudApiError(409); }; + const signal = new AbortController().signal; + await assert.rejects(boxMode.readBoxMode(api, box, signal), (error) => error.status === 404); + assert.equal(proofs, 0); + assert.deepEqual(JSON.parse(JSON.stringify(await boxMode.readBoxMode(api, box, signal, proof))), { mode: 'compatibility', keyEpoch: null }); + denied = true; await assert.rejects(boxMode.readBoxMode(api, box, signal, proof), (error) => error.status === 409); + denied = false; await assert.rejects(boxMode.readBoxMode(api, box, signal, proof, true), (error) => error.status === 404); + assert.equal(proofs, 2); +}); + +test('legacy archive never reads or uploads file bytes for encrypted/rejected modes, while proven compatibility works', async () => { + let modeStatus = 200, encrypted = true, metadataStatus = 200, reads = 0, uploads = 0; + const imports = load('../../shared/lib/1pux-import.ts', {}, { fetch: async (url, init) => { + if (url.endsWith('/csrf')) return new Response('{"csrfToken":"synthetic-csrf"}'); + if (url.endsWith('/login')) return new Response('{}'); + if (url.includes('/api/encrypted/')) return new Response(JSON.stringify({ mode: encrypted ? 'encrypted' : 'compatibility', keyEpoch: encrypted ? 17 : null }), { status: modeStatus }); + if (url.endsWith('/connect-token')) return new Response('{"accessToken":"synthetic-connect"}'); + if (url.endsWith(`/v1/vaults/${box}`)) return new Response('{}', { status: metadataStatus }); + if (url.endsWith('/imports/1pux')) { uploads++; assert.equal(atob(JSON.parse(init.body).base64), '\u0000\u00ff'); return new Response('{"imported":1}'); } + throw new Error('Unexpected route'); + } }); + const file = { arrayBuffer: async () => { reads++; return new Uint8Array([0, 255]).buffer; } }; + const run = () => imports.import1puxWithSession('https://example.invalid', box, file, 'synthetic@example.invalid', 'synthetic-login'); + await assert.rejects(run()); assert.equal(reads, 0); assert.equal(uploads, 0); + modeStatus = 404; metadataStatus = 409; await assert.rejects(run()); assert.equal(reads, 0); assert.equal(uploads, 0); + metadataStatus = 200; await run(); assert.equal(reads, 1); assert.equal(uploads, 1); + encrypted = false; modeStatus = 200; await run(); assert.equal(reads, 2); assert.equal(uploads, 2); +}); diff --git a/packages/extension/src/background/service-worker.ts b/packages/extension/src/background/service-worker.ts index 0e89e30a..48731d05 100644 --- a/packages/extension/src/background/service-worker.ts +++ b/packages/extension/src/background/service-worker.ts @@ -27,10 +27,11 @@ import { generatePassword } from "../lib/password-generator"; import { clearAllAuthStorage, loadRefreshToken, - loadStoredConnectToken, + loadStoredAuthSession, saveConnectToken, } from "../lib/storage"; import { bootstrapConnectSession } from "@shared/lib/connect-session"; +import { readBoxMode } from "@shared/cloud/box-mode"; import { createCloudSession } from "@shared/cloud/session-transport"; async function getConnectClient() { @@ -38,21 +39,13 @@ async function getConnectClient() { const result = await bootstrapConnectSession( { async load() { - const refreshToken = await loadRefreshToken(); - heldToken = refreshToken; - if (!refreshToken) { - return null; - } - const connect = await loadStoredConnectToken(); - return { - refreshToken, - connectAccessToken: connect?.token ?? "", - connectExpiresAt: connect?.expiresAt ?? "", - }; + const session = await loadStoredAuthSession(); + heldToken = session?.refreshToken ?? null; + return session; }, - save: (session) => saveConnectToken(session.connectAccessToken, session.connectExpiresAt), + save: (session) => saveConnectToken(session.connectAccessToken, session.connectExpiresAt, session.refreshToken), async clear() { - await clearAllAuthStorage(); + await clearAllAuthStorage(heldToken ?? undefined); // The stored user is gone; drop any cached credential matches too. }, @@ -60,7 +53,7 @@ async function getConnectClient() { { fetchCurrentUser, refreshConnectToken }, ); - if (!result || !heldToken) { + if (!result || !heldToken || await loadRefreshToken() !== heldToken) { return null; } @@ -82,7 +75,7 @@ async function fetchCredentialMatches( const matches: CredentialMatch[] = []; for (const vault of vaults) { - const mode = await cloud.api.getBoxState(vault.id); + const mode = await readBoxMode(cloud.api, vault.id, new AbortController().signal, async (id) => { await client.getVault(id); }); if (mode.mode === "encrypted") continue; const items = await client.listItems(vault.id); for (const overview of items) { @@ -134,7 +127,7 @@ async function saveCredential(payload: { throw new Error("No password box available"); } - const mode = await cloud.api.getBoxState(vault.id); + const mode = await readBoxMode(cloud.api, vault.id, new AbortController().signal, async (id) => { await client.getVault(id); }); if (mode.mode !== "compatibility") throw new Error("Encrypted autofill is unavailable"); const body = connectItemToCreateBody({ title: payload.title, @@ -234,7 +227,7 @@ chrome.runtime.onMessage.addListener((message: BackgroundRequest, _sender, sendR const { client, cloud } = auth; try { if (cloud.userId !== message.accountId) throw new Error("Not authenticated"); - const mode = await cloud.api.getBoxState(message.vaultId); + const mode = await readBoxMode(cloud.api, message.vaultId, new AbortController().signal, async (id) => { await client.getVault(id); }); if (mode.mode !== "compatibility") throw new Error("Encrypted autofill is unavailable"); const item = await client.getItem(message.vaultId, message.itemId); const mapped = connectItemFromApi(item); diff --git a/packages/extension/src/lib/session-persist.ts b/packages/extension/src/lib/session-persist.ts index bbe1deb7..f73b7890 100644 --- a/packages/extension/src/lib/session-persist.ts +++ b/packages/extension/src/lib/session-persist.ts @@ -1,7 +1,6 @@ import type { TokenAuthResponse } from "./auth-api"; -import { saveConnectToken, saveRefreshToken } from "./storage"; +import { saveStoredAuthSession } from "./storage"; -export async function persistAuthSession(response: TokenAuthResponse): Promise<void> { - await saveRefreshToken(response.refreshToken); - await saveConnectToken(response.connectAccessToken, response.connectExpiresAt); +export async function persistAuthSession(response: TokenAuthResponse, isCurrent = () => true): Promise<boolean> { + return saveStoredAuthSession(response, isCurrent); } diff --git a/packages/extension/src/lib/storage.ts b/packages/extension/src/lib/storage.ts index f853db1d..d497e628 100644 --- a/packages/extension/src/lib/storage.ts +++ b/packages/extension/src/lib/storage.ts @@ -1,4 +1,11 @@ import { STORAGE_KEYS } from "./constants"; +import type { StoredConnectSession } from "@shared/lib/connect-session"; + +// Existing Chrome storage transport, serialized across popup/background. +// Only formal session credentials are persisted here; never encryption keys. +async function withSessionStorage<T>(action: () => Promise<T>): Promise<T> { + return await navigator.locks.request("passshare.extension.session", action); +} function storageGet<T>(keys: string[]): Promise<Record<string, T>> { return new Promise((resolve, reject) => { @@ -37,7 +44,7 @@ function storageRemove(keys: string[]): Promise<void> { } export async function saveRefreshToken(token: string): Promise<void> { - await storageSet({ [STORAGE_KEYS.refreshToken]: token }); + await withSessionStorage(() => storageSet({ [STORAGE_KEYS.refreshToken]: token })); } export async function loadRefreshToken(): Promise<string | null> { @@ -46,10 +53,35 @@ export async function loadRefreshToken(): Promise<string | null> { return typeof token === "string" && token.length > 0 ? token : null; } -export async function saveConnectToken(token: string, expiresAt: string): Promise<void> { - await storageSet({ - [STORAGE_KEYS.connectToken]: token, - [STORAGE_KEYS.connectExpiresAt]: expiresAt, +export async function saveConnectToken(token: string, expiresAt: string, expectedRefreshToken?: string, isCurrent = () => true): Promise<void> { + await withSessionStorage(async () => { + if (!isCurrent() || (expectedRefreshToken !== undefined && await loadRefreshToken() !== expectedRefreshToken)) return; + await storageSet({ + [STORAGE_KEYS.connectToken]: token, + [STORAGE_KEYS.connectExpiresAt]: expiresAt, + }); + }); +} + +export async function loadStoredAuthSession(): Promise<StoredConnectSession | null> { + const stored = await storageGet<string>([STORAGE_KEYS.refreshToken, STORAGE_KEYS.connectToken, STORAGE_KEYS.connectExpiresAt]); + const refreshToken = stored[STORAGE_KEYS.refreshToken]; + if (typeof refreshToken !== "string" || !refreshToken) return null; + return { refreshToken, + connectAccessToken: typeof stored[STORAGE_KEYS.connectToken] === "string" ? stored[STORAGE_KEYS.connectToken] : "", + connectExpiresAt: typeof stored[STORAGE_KEYS.connectExpiresAt] === "string" ? stored[STORAGE_KEYS.connectExpiresAt] : "" }; +} + +export async function saveStoredAuthSession(session: StoredConnectSession, isCurrent = () => true): Promise<boolean> { + return withSessionStorage(async () => { + if (!isCurrent()) return false; + await storageSet({ [STORAGE_KEYS.refreshToken]: session.refreshToken, + [STORAGE_KEYS.connectToken]: session.connectAccessToken, [STORAGE_KEYS.connectExpiresAt]: session.connectExpiresAt }); + if (isCurrent()) return true; + // A stale write owns this lock. Remove only its own just-written session + // before a newer popup/background storage operation may enter. + if (await loadRefreshToken() === session.refreshToken) await removeAuthStorage(); + return false; }); } @@ -71,7 +103,7 @@ export async function loadStoredConnectToken(): Promise<{ token: string; expires return { token, expiresAt: typeof expiresAt === "string" ? expiresAt : "" }; } -export async function clearAllAuthStorage(): Promise<void> { +async function removeAuthStorage(): Promise<void> { await storageRemove([ STORAGE_KEYS.refreshToken, STORAGE_KEYS.connectToken, @@ -79,6 +111,13 @@ export async function clearAllAuthStorage(): Promise<void> { ]); } +export async function clearAllAuthStorage(expectedRefreshToken?: string, isCurrent = () => true): Promise<void> { + await withSessionStorage(async () => { + if (!isCurrent() || (expectedRefreshToken !== undefined && await loadRefreshToken() !== expectedRefreshToken)) return; + await removeAuthStorage(); + }); +} + export async function loadLocale(): Promise<string | null> { const result = await storageGet<string>([STORAGE_KEYS.locale]); const locale = result[STORAGE_KEYS.locale]; diff --git a/packages/extension/src/popup/components/ItemsPanel.tsx b/packages/extension/src/popup/components/ItemsPanel.tsx index 4eedacad..3b35eea0 100644 --- a/packages/extension/src/popup/components/ItemsPanel.tsx +++ b/packages/extension/src/popup/components/ItemsPanel.tsx @@ -1,6 +1,6 @@ import { useI18n } from "@shared/i18n/I18nProvider"; import { CloudBoxPanel } from "@shared/cloud/CloudBoxPanel"; -import { useEffect, useMemo, useState, useRef } from "react"; +import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import type { ConnectItem } from "@shared/types/item.js"; import { copyTextWithAutoClear, @@ -39,6 +39,11 @@ async function copySecretWithAutoClear(value: string): Promise<void> { export function ItemsPanel() { const { cloudSession, connectClient } = useAuthSession(); + const metadataProof = useCallback(async (id: string, signal: AbortSignal) => { + signal.throwIfAborted(); + if (!connectClient) throw new Error("Session unavailable"); + await connectClient.getVault(id); signal.throwIfAborted(); + }, [connectClient]); const { t } = useI18n(); const [boxes, setBoxes] = useState<Array<{ id: string; name: string }>>([]); const [selected, setSelected] = useState(""); @@ -57,7 +62,7 @@ export function ItemsPanel() { {boxes.map((box) => <option key={box.id} value={box.id}>{box.name}</option>)} </select></label> {failed && <p role="alert">{t("app.item.loadError")}</p>} - {selected && <CloudBoxPanel key={`${cloudSession.userId}:${selected}`} session={cloudSession} boxId={selected} + {selected && <CloudBoxPanel key={`${cloudSession.userId}:${selected}`} session={cloudSession} boxId={selected} metadataProof={metadataProof} compatibility={(ensureCompatibility) => <CompatibilityItemsPanel boxId={selected} ensureCompatibility={ensureCompatibility} />} />} </section>; } diff --git a/packages/extension/src/popup/hooks/useAuthSession.tsx b/packages/extension/src/popup/hooks/useAuthSession.tsx index 665d8985..b8c069d8 100644 --- a/packages/extension/src/popup/hooks/useAuthSession.tsx +++ b/packages/extension/src/popup/hooks/useAuthSession.tsx @@ -24,9 +24,8 @@ import { import { clearAllAuthStorage, loadRefreshToken, - loadStoredConnectToken, + loadStoredAuthSession, saveConnectToken, - saveRefreshToken, } from "../../lib/storage"; import { bootstrapConnectSession } from "@shared/lib/connect-session"; import { createCloudSession, type CloudSession } from "@shared/cloud/session-transport"; @@ -43,7 +42,7 @@ type AuthSessionContextValue = { const AuthSessionContext = createContext<AuthSessionContextValue | null>(null); -async function buildConnectClientFromStorage(): Promise<{ +async function buildConnectClientFromStorage(isCurrent: () => boolean): Promise<{ user: AuthUser | null; client: ConnectClient | null; refreshToken: string | null; @@ -52,25 +51,17 @@ async function buildConnectClientFromStorage(): Promise<{ const result = await bootstrapConnectSession( { async load() { - const refreshToken = await loadRefreshToken(); - heldToken = refreshToken; - if (!refreshToken) { - return null; - } - const connect = await loadStoredConnectToken(); - return { - refreshToken, - connectAccessToken: connect?.token ?? "", - connectExpiresAt: connect?.expiresAt ?? "", - }; + const session = await loadStoredAuthSession(); + heldToken = session?.refreshToken ?? null; + return session; }, - save: (session) => saveConnectToken(session.connectAccessToken, session.connectExpiresAt), - clear: clearAllAuthStorage, + save: (session) => saveConnectToken(session.connectAccessToken, session.connectExpiresAt, session.refreshToken, isCurrent), + clear: () => clearAllAuthStorage(heldToken ?? undefined, isCurrent), }, { fetchCurrentUser, refreshConnectToken }, ); - if (!result) { + if (!result || !isCurrent() || await loadRefreshToken() !== heldToken) { return { user: null, client: null, refreshToken: null }; } @@ -88,12 +79,14 @@ export function AuthSessionProvider({ children }: { children: ReactNode }) { const [cloudSession, setCloudSession] = useState<CloudSession | null>(null); const cloudRef = useRef<CloudSession | null>(null); const generation = useRef(0); + const installedToken = useRef<string | null>(null); function clearCloud() { - ++generation.current; + ++generation.current; installedToken.current = null; cloudRef.current?.dispose(); cloudRef.current = null; setCloudSession(null); window.dispatchEvent(new Event("passshare:cloud-lock")); } function bindCloud(id: string, token: string, epoch: number) { + installedToken.current = token; cloudRef.current = createCloudSession(getApiBaseUrl(), id, token, () => epoch === generation.current); setCloudSession(cloudRef.current); } @@ -102,7 +95,7 @@ export function AuthSessionProvider({ children }: { children: ReactNode }) { const epoch = generation.current; void (async () => { try { - const { user: storedUser, client, refreshToken } = await buildConnectClientFromStorage(); + const { user: storedUser, client, refreshToken } = await buildConnectClientFromStorage(() => epoch === generation.current); if (epoch !== generation.current) return; if (storedUser && refreshToken) bindCloud(storedUser.id, refreshToken, epoch); setUser(storedUser); @@ -114,10 +107,26 @@ export function AuthSessionProvider({ children }: { children: ReactNode }) { return () => { ++generation.current; cloudRef.current?.dispose(); cloudRef.current = null; }; }, []); + useEffect(() => { + const changed = (changes: Record<string, chrome.storage.StorageChange>, area: string) => { + if (area === "local" && installedToken.current && changes["passshare.refreshToken"] && + changes["passshare.refreshToken"].newValue !== installedToken.current) { + clearCloud(); setUser(null); setConnectClient(null); + } + }; + chrome.storage.onChanged.addListener(changed); + return () => chrome.storage.onChanged.removeListener(changed); + }, []); + const applyAuth = useCallback(async (response: TokenAuthResponse, epoch: number) => { if (epoch !== generation.current) { await extensionLogout(response.refreshToken).catch(() => {}); return; } - await persistAuthSession(response); - if (epoch !== generation.current) return; + const saved = await persistAuthSession(response, () => epoch === generation.current); + if (!saved) { await extensionLogout(response.refreshToken).catch(() => {}); return; } + if (epoch !== generation.current) { + await clearAllAuthStorage(response.refreshToken); + await extensionLogout(response.refreshToken).catch(() => {}); + return; + } bindCloud(response.user.id, response.refreshToken, epoch); setUser(response.user); setConnectClient( @@ -160,6 +169,7 @@ export function AuthSessionProvider({ children }: { children: ReactNode }) { const logout = useCallback(async () => { clearCloud(); setUser(null); setConnectClient(null); + const epoch = generation.current; const refreshToken = await loadRefreshToken(); if (refreshToken) { try { @@ -168,9 +178,8 @@ export function AuthSessionProvider({ children }: { children: ReactNode }) { // Clear local state even if remote logout fails. } } - await clearAllAuthStorage(); - setUser(null); - setConnectClient(null); + await clearAllAuthStorage(refreshToken ?? undefined, () => epoch === generation.current); + if (epoch === generation.current) { setUser(null); setConnectClient(null); } }, []); const value = useMemo( diff --git a/shared/cloud/CloudBoxPanel.tsx b/shared/cloud/CloudBoxPanel.tsx index b7c9da34..cd0d4287 100644 --- a/shared/cloud/CloudBoxPanel.tsx +++ b/shared/cloud/CloudBoxPanel.tsx @@ -7,10 +7,12 @@ import type { CloudSession } from "./session-transport"; import { EncryptedItems } from "./EncryptedItems"; import { SharedCloudSharingPanel } from "./CloudSharingPanel"; import "./client-cloud.css"; +import { readBoxMode, type CompatibilityProof } from "./box-mode"; // Unknown/failed/encrypted state never mounts a legacy item renderer. -export function CloudBoxPanel({ session, boxId, compatibility, split = false }: { +export function CloudBoxPanel({ session, boxId, compatibility, metadataProof, split = false }: { session: CloudSession; boxId: string; compatibility?: (ensureCompatibility: () => Promise<void>) => ReactNode; split?: boolean; + metadataProof?: CompatibilityProof; }) { const { t } = useI18n(); const [state, setState] = useState<BoxState | null>(null); @@ -24,6 +26,7 @@ export function CloudBoxPanel({ session, boxId, compatibility, split = false }: const alive = useRef(false); const observedEncrypted = useRef(false); const worker = useRef<CloudWorker | null>(null); + const heldEpoch = useRef<number | null>(null); const operation = useRef<{ controller: AbortController; secret: string } | null>(null); const checks = useRef(new Set<AbortController>()); const checkedBinding = useRef<{ session: CloudSession; boxId: string } | null>(null); @@ -38,6 +41,7 @@ export function CloudBoxPanel({ session, boxId, compatibility, split = false }: operation.current = null; for (const controller of checks.current) controller.abort(); checks.current.clear(); checkedBinding.current = null; worker.current?.dispose(); worker.current = null; + heldEpoch.current = null; if (password.current) password.current.value = ""; setOpened(false); setScope(null); setRecipient(null); setBusy(false); setError(null); setState(null); if (alive.current) setCheck((n) => n + 1); @@ -62,10 +66,13 @@ export function CloudBoxPanel({ session, boxId, compatibility, split = false }: const generation = lifetime.current; const controller = new AbortController(); checks.current.add(controller); - void session.api.getBoxState(boxId, controller.signal).then(async (next) => { + void readBoxMode(session.api, boxId, controller.signal, compatibility ? metadataProof : undefined, observedEncrypted.current).then(async (next) => { if (!active(generation) || controller.signal.aborted) return; if (next.mode === "encrypted") observedEncrypted.current = true; else if (observedEncrypted.current) throw new CloudApiError(409, "PS056"); + if (worker.current && next.mode === "encrypted" && heldEpoch.current !== null && next.keyEpoch !== heldEpoch.current) { + lock(); return; + } if (next.mode === "encrypted") { const members = await session.listVaultMembers(boxId, controller.signal); if (!active(generation) || controller.signal.aborted) return; @@ -75,17 +82,20 @@ export function CloudBoxPanel({ session, boxId, compatibility, split = false }: } checkedBinding.current = { session, boxId }; setState(next); - }).catch(() => { if (active(generation) && !controller.signal.aborted) setError("cloud.failed"); }) + }).catch(() => { if (active(generation) && !controller.signal.aborted) { + if (worker.current) lock(); + setError("cloud.failed"); + } }) .finally(() => checks.current.delete(controller)); return () => { controller.abort(); checks.current.delete(controller); }; - }, [session, boxId, check]); + }, [session, boxId, check, metadataProof]); async function ensureCompatibility() { const generation = lifetime.current; if (!active(generation) || observedEncrypted.current) throw new CloudApiError(409); const controller = new AbortController(); checks.current.add(controller); try { - const next = await session.api.getBoxState(boxId, controller.signal); + const next = await readBoxMode(session.api, boxId, controller.signal, metadataProof, observedEncrypted.current); if (!active(generation) || controller.signal.aborted) throw new CloudApiError(401); if (next.mode !== "compatibility") { observedEncrypted.current = true; setState(next); throw new CloudApiError(409); } } finally { checks.current.delete(controller); } @@ -108,6 +118,7 @@ export function CloudBoxPanel({ session, boxId, compatibility, split = false }: const self = members.find((member) => member.userId === session.userId); if (!self || (self.role !== "owner" && self.identityConfirmed !== true)) throw new CloudApiError(403); const held = { passwordBoxId: boxId, ownerAccountId: session.userId, keyEpoch: state.keyEpoch }; + heldEpoch.current = held.keyEpoch; const crypto = new CloudWorker(); worker.current = crypto; void crypto.closed.then(() => { if (active(generation) && worker.current === crypto) lock(); }); await crypto.ready; diff --git a/shared/cloud/box-mode.ts b/shared/cloud/box-mode.ts new file mode 100644 index 00000000..58aa0f68 --- /dev/null +++ b/shared/cloud/box-mode.ts @@ -0,0 +1,18 @@ +import { CloudApiError, type BoxState, type CloudApi } from "./cloud-api"; + +export type CompatibilityProof = (boxId: string, signal: AbortSignal) => Promise<void>; + +// The existing Web gate's flag-off path: 404 alone is never compatibility. +// The formal Connect metadata route preserves the DB ciphertext boundary even +// with the feature disabled, and returns 409 for an encrypted password box. +export async function readBoxMode(api: CloudApi, boxId: string, signal: AbortSignal, + metadataProof?: CompatibilityProof, encryptedObserved = false): Promise<BoxState> { + try { return await api.getBoxState(boxId, signal); } + catch (error) { + if (!(error instanceof CloudApiError) || error.status !== 404 || !metadataProof || encryptedObserved) throw error; + signal.throwIfAborted(); + await metadataProof(boxId, signal); + signal.throwIfAborted(); + return { mode: "compatibility", keyEpoch: null }; + } +} diff --git a/shared/cloud/session-transport.ts b/shared/cloud/session-transport.ts index 2076ac2c..2faca2c0 100644 --- a/shared/cloud/session-transport.ts +++ b/shared/cloud/session-transport.ts @@ -22,6 +22,8 @@ export function createCloudSession(base: string, userId: string, sessionToken: s init.signal?.throwIfAborted(); init.signal?.addEventListener("abort", abort, { once: true }); requests.add(controller); + let bodyOwned = false; + const cleanup = () => { requests.delete(controller); init.signal?.removeEventListener("abort", abort); }; try { const headers = new Headers(init.headers); headers.set("Authorization", `Bearer ${sessionToken}`); @@ -31,17 +33,46 @@ export function createCloudSession(base: string, userId: string, sessionToken: s await response.body?.cancel().catch(() => {}); throw new CloudApiError(401); } - return response; + if (!response.body) return response; + const reader = response.body.getReader(); + const cancelBody = () => { void reader.cancel().catch(() => {}); }; + controller.signal.addEventListener("abort", cancelBody, { once: true }); + let finished = false; + const finish = () => { + if (finished) return; + finished = true; cleanup(); controller.signal.removeEventListener("abort", cancelBody); reader.releaseLock(); + }; + // Keep the account lifetime and caller abort bound through body EOF, + // not merely until fetch has returned headers. + const body = new ReadableStream<Uint8Array>({ + async pull(destination) { + try { + if (!current() || controller.signal.aborted) throw new CloudApiError(401); + const part = await reader.read(); + if (!current() || controller.signal.aborted) { part.value?.fill(0); throw new CloudApiError(401); } + if (part.done) { destination.close(); finish(); } + else destination.enqueue(part.value); + } catch (error) { + await reader.cancel().catch(() => {}); + destination.error(error instanceof CloudApiError ? error : new CloudApiError(0)); finish(); + } + }, + async cancel() { try { await reader.cancel(); } finally { finish(); } }, + }); + try { + const result = new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers }); + bodyOwned = true; + return result; + } catch { await body.cancel().catch(() => {}); finish(); throw new CloudApiError(0); } } finally { - requests.delete(controller); - init.signal?.removeEventListener("abort", abort); + if (!bodyOwned) cleanup(); } }; return Object.freeze({ userId, api: createCloudApi(transport), sharing: createSharingApi(transport), transport, async listVaultMembers(boxId: string, signal: AbortSignal): Promise<VaultMember[]> { const response = await transport(`/api/auth/vaults/${encodeURIComponent(boxId)}/members`, { signal }); - if (response.status !== 200) throw new CloudApiError(response.status); + if (response.status !== 200) { await response.body?.cancel().catch(() => {}); throw new CloudApiError(response.status); } const body = await response.json() as { members: VaultMember[] }; if (!Array.isArray(body.members)) throw new CloudApiError(502); return body.members; diff --git a/shared/cloud/sharing-api.ts b/shared/cloud/sharing-api.ts index e5bbe93c..c6cbbdd4 100644 --- a/shared/cloud/sharing-api.ts +++ b/shared/cloud/sharing-api.ts @@ -22,7 +22,7 @@ export function createSharingApi(authFetch: CloudFetch) { async function request(url: string, signal: AbortSignal, body?: unknown): Promise<any> { const response = await authFetch(url, { signal, cache: "no-store", redirect: "error", ...(body === undefined ? {} : { method: "PUT", body: JSON.stringify(body) }) }); - if (response.status !== 200) throw new CloudApiError(response.status); + if (response.status !== 200) { await response.body?.cancel().catch(() => {}); throw new CloudApiError(response.status); } const reader = response.body?.getReader(); if (!reader) throw new CloudApiError(502); const decoder = new TextDecoder("utf-8", { fatal: true }); diff --git a/shared/lib/1pux-import.ts b/shared/lib/1pux-import.ts index fd3dbadf..29acaba5 100644 --- a/shared/lib/1pux-import.ts +++ b/shared/lib/1pux-import.ts @@ -54,9 +54,11 @@ export async function import1puxWithSession( const modeResponse = await fetch(`${apiBaseUrl}/api/encrypted/boxes/${encodeURIComponent(vaultId)}`, { credentials: "include", cache: "no-store", redirect: "error", }); - if (!modeResponse.ok) throw new Error("Password box mode unavailable"); - const mode = await modeResponse.json() as { mode: string; keyEpoch: number | null }; - if (mode.mode !== "compatibility" || mode.keyEpoch !== null) throw new Error("Encrypted password box requires encrypted migration"); + if (modeResponse.status !== 404) { + if (!modeResponse.ok) throw new Error("Password box mode unavailable"); + const mode = await modeResponse.json() as { mode: string; keyEpoch: number | null }; + if (mode.mode !== "compatibility" || mode.keyEpoch !== null) throw new Error("Encrypted password box requires encrypted migration"); + } const tokenRes = await fetch(`${apiBaseUrl}/api/auth/connect-token`, { credentials: "include", @@ -66,6 +68,14 @@ export async function import1puxWithSession( } const tokenBody = (await tokenRes.json()) as { accessToken: string }; + if (modeResponse.status === 404) { + // Metadata only; flag-off still preserves the server's ciphertext boundary. + const metadata = await fetch(`${apiBaseUrl}/v1/vaults/${encodeURIComponent(vaultId)}`, { + headers: { Authorization: `Bearer ${tokenBody.accessToken}` }, cache: "no-store", redirect: "error", + }); + if (metadata.status !== 200) throw new Error("Compatibility password box unavailable"); + } + const buffer = await file.arrayBuffer(); const base64 = bufferToBase64(buffer); const importRes = await fetch(`${apiBaseUrl}/v1/vaults/${vaultId}/imports/1pux`, { From b1b5d479e25de3b428ff89a1993556748b313725 Mon Sep 17 00:00:00 2001 From: mashfromband <matsumoto.yukuhashi@gmail.com> Date: Sun, 11 Oct 2026 19:21:14 +0900 Subject: [PATCH 08/10] fix(ui): preserve drafts on metadata outages and close prior local scope --- apps/desktop/src/App.tsx | 19 ++++++++- .../spec/cross-client-encrypted-items-ui.md | 8 +++- frontend/tests/cross-client-cloud.test.mjs | 40 ++++++++++++++++++- shared/cloud/CloudBoxPanel.tsx | 4 +- shared/cloud/cloud-api.ts | 2 +- 5 files changed, 65 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f4cb4521..e4444b93 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -13,6 +13,8 @@ import { useI18n } from "@shared/i18n/I18nProvider"; export function App() { const { t } = useI18n(); const [cloud, setCloud] = useState(false); + const [switching, setSwitching] = useState(false); + const [cloudError, setCloudError] = useState(false); const [view, setView] = useState<AppView>("setup"); const [boxPath, setBoxPath] = useState(""); const [masterPassword, setMasterPassword] = useState(""); @@ -68,14 +70,29 @@ export function App() { nativeBinding.current?.accept(await invoke<NativeStatus>("native_session_status")); } + async function switchCloud() { + if (switching) return; + if (cloud) { setCloud(false); return; } + setSwitching(true); setCloudError(false); + try { + // An account-scoped cloud view must not leave a previously opened .pbox + // or its master-password draft alive behind the account switch. + await invoke<void>("lock_password_box"); + handleLocked(); + if (!document.hidden) setCloud(true); + } catch { setCloudError(true); } + finally { setSwitching(false); } + } + return ( <div className="ps-app-shell"> <MatrixBackground className="matrix-rain--fixed" /> <main className="ps-app"> <AppHeader /> - <button type="button" className="ps-button ps-button--secondary" onClick={() => setCloud((value) => !value)}> + <button type="button" className="ps-button ps-button--secondary" disabled={switching} onClick={() => void switchCloud()}> {cloud ? t("app.vault.back") : t("cloud.openSubmit")} </button> + {cloudError && <p className="ps-error" role="alert">{t("cloud.unavailable")}</p>} {cloud ? <CloudItemsPanel /> : view === "setup" ? ( <SetupPanel key={lockGeneration} diff --git a/documents/spec/cross-client-encrypted-items-ui.md b/documents/spec/cross-client-encrypted-items-ui.md index 9d334c63..041ea2ab 100644 --- a/documents/spec/cross-client-encrypted-items-ui.md +++ b/documents/spec/cross-client-encrypted-items-ui.md @@ -43,6 +43,8 @@ Desktopは広い画面で一覧と編集を並べ、Mobileと拡張は1列にす 秘密鍵をstorage/DB/ログへ書かない。JS文字列のメモリ全域の物理消去は保証しない。 Desktopの`.pbox`、緊急キット復旧、保存失敗保持は既存経路を保つ。 +クラウド画面へ明示的に切り替えるときは既存のnative lockコマンドで開いていた`.pbox`を閉じ、 +そのマスターパスワードと下書きを別アカウントの背後へ残さない。保存ファイルを書き換える処理は追加しない。 旧cloud-syncと1PUX取込は、平文を読む/送る前に互換モードを要求する。 暗号化モードを旧同期へ流さない。暗号化ボックスと`.pbox`の自動同期はこの接続の対象外。 @@ -70,7 +72,7 @@ TauriとMV3のscript-srcには `'self' 'wasm-unsafe-eval'`、worker-srcには `' 2026-10-11に、Web・3クライアントのViteビルド、型検査、10言語481キーが成功。 3クライアント×3個の同梱アセットすべてで正式manifestとSHA256が一致。 -共通項目・添付25件、新しいsession/画面寿命・背景探索26件、既存Desktop52件・Mobile18件・拡張41件が成功。 +共通項目・添付25件、新しいsession/画面寿命・背景探索30件、既存Desktop52件・Mobile18件・拡張41件が成功。 新しい検査は別セッションの同時通信、epoch変更後の遅い応答、dispose/abort、実HTTP redirect非追従、 401/403/409/404、互換からの暗号化切替、解除中のlock/背景/unmount、受信者の独立pin経路を扱う。 項目・添付の検査は全名前・空ファイル・259byteの全bytes、409/応答喪失、取消・遅いFile読取りを扱う。 @@ -89,7 +91,9 @@ CUAでのタブ取得は37秒で時間切れとなった。画面配置・320px 最初の固定01b0f30のGPT-5.5独立レビューはMust/Suggestion/Nit 0。 追加の点検で本文処理の所有寿命と拡張の遅い保存を補強し、対応する取消/EOF/保存競合の検査を追加した。 フラグ無効環境の互換経路は既存Webのmetadata確認と同じ条件へ合わせ、添付の全名前も直接照合した。 -追加差分も独立レビューする。 +追加差分b1064dcのGPT-5.5レビューで、通信失敗のmetadata更新でも下書きを閉じるMust 1件を受領した。 +失効401/403・世代不一致の場合だけ閉じ、通信0/502/503は同じworkerと編集画面を保持するよう直した。 +対応する3検査とDesktopの明示的な`.pbox`終了を追加し、修正差分を再レビューする。 最新の統合ソースに対するroot全workspace/doctest、全CI、正式Node/PGとの3クライアント往復、 MV3実popupのCSPと320px、Tauri/native WebViewの実保存/背景/再起動、全クライアントの復旧・失効・移行は親が正式に確認する。 親C1 fixtureと親PGは操作していない。新DB/アカウントは作成していない。 diff --git a/frontend/tests/cross-client-cloud.test.mjs b/frontend/tests/cross-client-cloud.test.mjs index 9af19447..a12cf434 100644 --- a/frontend/tests/cross-client-cloud.test.mjs +++ b/frontend/tests/cross-client-cloud.test.mjs @@ -17,7 +17,7 @@ function load(name, imports = {}, globals = {}) { }).outputText, { exports, require: (id) => { if (!(id in imports)) throw new Error(`Unmapped import ${id}`); return imports[id]; - }, URL, Headers, Response, AbortController, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, + }, URL, Headers, Response, AbortController, Event, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, ReadableStream, setTimeout, clearTimeout, atob, btoa, ...globals }); return exports; } @@ -53,7 +53,7 @@ function host({ mode = 'encrypted', failure = null, delayedUnlock = false, recip './cloud-api': cloudApi, './cloud-worker': { CloudWorker: Worker }, './EncryptedItems': { EncryptedItems: Items }, './box-mode': boxMode, './CloudSharingPanel': { SharedCloudSharingPanel: Sharing }, './client-cloud.css': {}, - }, { window, document }); + }, { window, document, Event }); const compatibility = (ensure) => { legacy.push(ensure); return React.createElement('output', null, 'compatibility-items'); }; renderer.act(() => { tree = renderer.create(React.createElement(CloudBoxPanel, { session, boxId: box, compatibility }), { createNodeMock: () => input }); }); return { tree, input, workers, window, document, legacy, unlocks, calls, session, @@ -131,6 +131,8 @@ test('a broken successful response body remains an unknown outcome, not an autho const session = createCloudSession('https://example.invalid', owner, 'synthetic-session'); const response = await session.transport('/api/encrypted/account-identity', { method: 'PUT', body: '{"opaque":"synthetic"}' }); await assert.rejects(response.text(), (error) => error.status === 0); session.dispose(); + const metadata = createCloudSession('https://example.invalid', owner, 'synthetic-session'); + await assert.rejects(metadata.api.getBoxState(box), (error) => error.status === 0); metadata.dispose(); }); test('authorization rejection cancels the unread response body rather than retaining its reader', async () => { @@ -194,6 +196,12 @@ for (const change of ['epoch', 'authorization']) test(`same-account metadata ref assert.equal(h.json().includes('"encrypted-items"'), false); await h.unmount(); }); +for (const status of [0, 502, 503]) test(`same-account metadata failure ${status} preserves the unlocked worker and current item screen`, async () => { + const h = host(); await flush(); await h.submit(); h.setFailure(status); + await h.recheck(); assert.equal(h.workers[0].isClosed, false); + assert.equal(h.json().includes('"encrypted-items"'), true); await h.unmount(); +}); + function backgroundHost() { let listener, mode = 'compatibility', account = owner, disposed = 0, details = 0, writes = 0; const chrome = { runtime: { onMessage: { addListener(fn) { listener = fn; } } }, alarms: { onAlarm: { addListener() {} } } }; @@ -310,3 +318,31 @@ test('legacy archive never reads or uploads file bytes for encrypted/rejected mo metadataStatus = 200; await run(); assert.equal(reads, 1); assert.equal(uploads, 1); encrypted = false; modeStatus = 200; await run(); assert.equal(reads, 2); assert.equal(uploads, 2); }); + +test('desktop cloud entry locks an open .pbox before another account may be shown', async () => { + let unlocked = true, fileLocks = 0, tree; + const window = events(), document = { hidden: false }; + const node = (text) => () => React.createElement('output', null, text); + const { App } = load('../../apps/desktop/src/App.tsx', { + react: React, 'react/jsx-runtime': requireApp('react/jsx-runtime'), + '../../../shared/ui/MatrixBackground': { MatrixBackground: () => null }, + '../../../shared/lib/native-lock': { bindNativeLock: () => ({ accept() {}, dispose() {} }) }, + '@tauri-apps/api/core': { isTauri: () => true, async invoke(name) { + if (name === 'lock_password_box') { unlocked = false; fileLocks++; return; } + return { unlocked, epoch: 1 }; + } }, + '@tauri-apps/api/event': { listen() {} }, './components/AppHeader': { AppHeader: () => null }, + './components/MainPanel': { MainPanel: node('local-secret-items') }, './components/SetupPanel': { SetupPanel: node('local-locked') }, + './components/CloudItemsPanel': { CloudItemsPanel: node('cloud-panel') }, + '@shared/i18n/I18nProvider': { useI18n: () => ({ t: (key) => key }) }, + }, { window, document }); + renderer.act(() => { tree = renderer.create(React.createElement(App)); }); await flush(); + assert.equal(JSON.stringify(tree.toJSON()).includes('local-secret-items'), true, JSON.stringify(tree.toJSON())); + await renderer.act(async () => tree.root.findByType('button').props.onClick()); await flush(); + assert.equal(fileLocks, 1); assert.equal(unlocked, false); + assert.equal(JSON.stringify(tree.toJSON()).includes('cloud-panel'), true); + await renderer.act(async () => tree.root.findByType('button').props.onClick()); await flush(); + assert.equal(JSON.stringify(tree.toJSON()).includes('local-secret-items'), false); + assert.equal(JSON.stringify(tree.toJSON()).includes('local-locked'), true); + renderer.act(() => tree.unmount()); +}); diff --git a/shared/cloud/CloudBoxPanel.tsx b/shared/cloud/CloudBoxPanel.tsx index cd0d4287..531b6226 100644 --- a/shared/cloud/CloudBoxPanel.tsx +++ b/shared/cloud/CloudBoxPanel.tsx @@ -82,8 +82,8 @@ export function CloudBoxPanel({ session, boxId, compatibility, metadataProof, sp } checkedBinding.current = { session, boxId }; setState(next); - }).catch(() => { if (active(generation) && !controller.signal.aborted) { - if (worker.current) lock(); + }).catch((error: unknown) => { if (active(generation) && !controller.signal.aborted) { + if (worker.current && error instanceof CloudApiError && ([401, 403].includes(error.status) || error.code === "PS056")) lock(); setError("cloud.failed"); } }) .finally(() => checks.current.delete(controller)); diff --git a/shared/cloud/cloud-api.ts b/shared/cloud/cloud-api.ts index 46d58135..107b53ae 100644 --- a/shared/cloud/cloud-api.ts +++ b/shared/cloud/cloud-api.ts @@ -27,7 +27,7 @@ export function createCloudApi(authFetch: CloudFetch) { const reply = await response.json(); if (!reply || typeof reply !== "object" || Array.isArray(reply)) throw new CloudApiError(502); return reply; - } catch { throw new CloudApiError(502); } + } catch (error) { if (error instanceof CloudApiError) throw error; throw new CloudApiError(502); } } async function getBoxState(boxId: string, signal?: AbortSignal): Promise<BoxState> { const state = await request(path(boxId), { signal }); From 7038b2e3e3fb082e4574a5ea90df58b445b0448f Mon Sep 17 00:00:00 2001 From: mashfromband <matsumoto.yukuhashi@gmail.com> Date: Mon, 12 Oct 2026 00:54:51 +0900 Subject: [PATCH 09/10] fix: separate recipient key capability and update mobile auth host --- crates/op-cli/src/encrypted.rs | 79 +++++++++++-------- .../examples/mobile_auth_acceptance.rs | 12 ++- documents/runbooks/codeql.md | 9 +++ documents/spec/encrypted-cli.md | 5 ++ 4 files changed, 69 insertions(+), 36 deletions(-) diff --git a/crates/op-cli/src/encrypted.rs b/crates/op-cli/src/encrypted.rs index 6d7a684a..e7be86e4 100644 --- a/crates/op-cli/src/encrypted.rs +++ b/crates/op-cli/src/encrypted.rs @@ -813,10 +813,13 @@ impl SharedReceive { } struct RecipientSession { - sdk: EncryptedSessionClient, - box_route: ConnectId, key: PasswordBoxKey, - trust: SharedReceive, +} +// Public inputs and transport are held independently of the private capability. +struct RecipientRead<'a> { + sdk: &'a EncryptedSessionClient, + box_route: &'a ConnectId, + trust: &'a SharedReceive, } impl RecipientSession { async fn open( @@ -824,10 +827,9 @@ impl RecipientSession { token: &str, password: &str, box_id: &str, - trust: SharedReceive, + trust: &SharedReceive, + sdk: &EncryptedSessionClient, ) -> Result<Self> { - let box_route = box_id.parse().map_err(|_| INPUT)?; - let sdk = EncryptedSessionClient::new(host, token)?; let subject_id = live_subject(host, token).await?; if subject_id != trust.recipient.account_id { return Err(CONTEXT); @@ -877,7 +879,7 @@ impl RecipientSession { return Err(CONTEXT); } let expected = trust.expectation(); - let grant = SharingRotationClient::new(&sdk) + let grant = SharingRotationClient::new(sdk) .get_self_grant(box_id, &expected) .await?; let key = PasswordBoxKey::unwrap_for_recipient( @@ -887,50 +889,56 @@ impl RecipientSession { &trust.context, ) .map_err(|_| CRYPTO)?; - Ok(Self { - sdk, - box_route, - key, - trust, - }) + // A private key capability never carries an HTTP client or route. + // Public transport and independently supplied expectations stay in the + // caller, separate from restored identity and decrypted key material. + Ok(Self { key }) } - async fn item_at(&self, revision: u64, expected: &ItemContext) -> Result<Plaintext> { - let envelope = self.sdk.get_attachment_snapshot(revision, expected).await?; + async fn item_at( + &self, + sdk: &EncryptedSessionClient, + revision: u64, + expected: &ItemContext, + ) -> Result<Plaintext> { + let envelope = sdk.get_attachment_snapshot(revision, expected).await?; Ok(Plaintext( self.key .decrypt_item(&envelope, expected) .map_err(|_| CRYPTO)?, )) } - async fn get(&self, id: &str) -> Result<Plaintext> { - let expected = self.trust.item(id)?; - let state = self + async fn get(&self, read: &RecipientRead<'_>, id: &str) -> Result<Plaintext> { + let expected = read.trust.item(id)?; + let state = read .sdk - .get_attachment_state(self.box_route.as_str(), self.trust.context.key_epoch) + .get_attachment_state(read.box_route.as_str(), read.trust.context.key_epoch) .await?; - self.item_at(state.state_revision, expected).await + self.item_at(read.sdk, state.state_revision, expected).await } async fn download( &self, + read: &RecipientRead<'_>, item: &str, attachment: &str, out: &std::path::Path, print_name: bool, ) -> Result<()> { - let expected_item = self.trust.item(item)?; - let expected = self + let expected_item = read.trust.item(item)?; + let expected = read .trust .attachment_context .as_ref() .filter(|c| c.attachment_id.as_str() == attachment) .ok_or(INPUT)?; - let state = self + let state = read .sdk - .get_attachment_state(self.box_route.as_str(), self.trust.context.key_epoch) + .get_attachment_state(read.box_route.as_str(), read.trust.context.key_epoch) + .await?; + let dto = self + .item_at(read.sdk, state.state_revision, expected_item) .await?; - let dto = self.item_at(state.state_revision, expected_item).await?; authenticated_reference(&dto.0, expected)?; - let envelope = self + let envelope = read .sdk .get_attachment(state.state_revision, expected) .await?; @@ -1071,10 +1079,17 @@ async fn run_recipient(cli: Cli, mut input: Plaintext) -> Result<()> { .passshare_host .or_else(|| std::env::var("PASSSHARE_HOST").ok()) .ok_or(INPUT)?; - let session = RecipientSession::open(&host, &token, &password, &box_id, trust).await?; + let box_route: ConnectId = box_id.parse().map_err(|_| INPUT)?; + let sdk = EncryptedSessionClient::new(&host, &token)?; + let session = RecipientSession::open(&host, &token, &password, &box_id, &trust, &sdk).await?; drop(token); drop(password); erase(&mut input.0); + let read = RecipientRead { + sdk: &sdk, + box_route: &box_route, + trust: &trust, + }; match cli.command { Commands::Item { command: ItemCommands::List { .. }, @@ -1082,11 +1097,11 @@ async fn run_recipient(cli: Cli, mut input: Plaintext) -> Result<()> { let mut cursor = None; let mut items = Vec::new(); loop { - let page = session.sdk.list(&box_id, cursor.as_deref()).await?; + let page = sdk.list(box_route.as_str(), cursor.as_deref()).await?; if page .items .iter() - .any(|i| i.key_epoch != session.trust.context.key_epoch) + .any(|i| i.key_epoch != trust.context.key_epoch) { return Err(CONTEXT); } @@ -1104,7 +1119,7 @@ async fn run_recipient(cli: Cli, mut input: Plaintext) -> Result<()> { Commands::Item { command: ItemCommands::Get { item, fields, .. }, } => { - let dto = session.get(&item).await?; + let dto = session.get(&read, &item).await?; if let Some(name) = fields { println!("{}", selected_value(&dto.0, &name)?); } else { @@ -1117,7 +1132,7 @@ async fn run_recipient(cli: Cli, mut input: Plaintext) -> Result<()> { } Commands::Read { reference } => { let r = passshare_connect::parse_op_reference(&reference).ok_or(INPUT)?; - let dto = session.get(&r.item).await?; + let dto = session.get(&read, &r.item).await?; println!("{}", selected_value(&dto.0, &r.field)?); } Commands::Attachment { @@ -1131,7 +1146,7 @@ async fn run_recipient(cli: Cli, mut input: Plaintext) -> Result<()> { }, } => { session - .download(&item, &attachment, &out, print_name) + .download(&read, &item, &attachment, &out, print_name) .await? } _ => return Err(INPUT), diff --git a/crates/passshare-core/examples/mobile_auth_acceptance.rs b/crates/passshare-core/examples/mobile_auth_acceptance.rs index 12903a1d..ec294dc3 100644 --- a/crates/passshare-core/examples/mobile_auth_acceptance.rs +++ b/crates/passshare-core/examples/mobile_auth_acceptance.rs @@ -5,7 +5,7 @@ use std::process::{Command, Stdio}; const HOST: &str = r#" import {registerHooks,createRequire} from 'node:module'; -import {readFileSync} from 'node:fs'; +import {readFileSync,existsSync} from 'node:fs'; import {resolve,join,dirname} from 'node:path'; import {pathToFileURL,fileURLToPath} from 'node:url'; const app=resolve(process.argv[1]); @@ -15,7 +15,7 @@ const root=resolve(app,'../..'); const deferred=()=>{let resolve,reject;const promise=new Promise((a,b)=>{resolve=a;reject=b});return {promise,resolve,reject}}; const doc=new EventTarget();doc.hidden=false;doc.hasFocus=()=>true; globalThis.document=doc; -globalThis.window={setInterval:()=>0,clearInterval:()=>{}}; +globalThis.window=Object.assign(new EventTarget(),{setInterval:()=>0,clearInterval:()=>{}}); globalThis.IS_REACT_ACT_ENVIRONMENT=true; globalThis.fetch=()=>{throw new Error('Unexpected real network access')}; const native={epoch:0,id:null,foreground:true,unlocked:false}; @@ -60,13 +60,17 @@ const modules={ '@tauri-apps/api/core':`export const isTauri=()=>true;export const invoke=(...args)=>globalThis.__mobileFixture.invoke(...args);`, '@tauri-apps/api/event':`export const listen=async(_,cb)=>globalThis.__mobileFixture.listen(cb);`, '@shared/i18n/I18nProvider':`export const useI18n=()=>({t:key=>key});`, - '../lib/auth-api':`export class AuthApiError extends Error{};export const getApiBaseUrl=()=> 'http://synthetic.invalid';export const mobileLogin=()=>globalThis.__mobileFixture.login();export const mobileSignup=mobileLogin;export const mobileLogout=()=>globalThis.__mobileFixture.logout();export const fetchCurrentUser=async()=>globalThis.__mobileFixture.user;export const refreshConnectToken=async()=>({connectAccessToken:'synthetic-access',connectExpiresAt:'2999-01-01'});`, + '../lib/auth-api':`export class AuthApiError extends Error{};export const getApiBaseUrl=()=> 'http://127.0.0.1:12345';export const mobileLogin=()=>globalThis.__mobileFixture.login();export const mobileSignup=mobileLogin;export const mobileLogout=()=>globalThis.__mobileFixture.logout();export const fetchCurrentUser=async()=>globalThis.__mobileFixture.user;export const refreshConnectToken=async()=>({connectAccessToken:'synthetic-access',connectExpiresAt:'2999-01-01'});`, }; fixture.user=user;fixture.listen=cb=>{listeners.add(cb);return ()=>listeners.delete(cb)}; registerHooks({ resolve(specifier,context,next){ if(modules[specifier])return {url:'data:text/javascript,'+encodeURIComponent(modules[specifier]),shortCircuit:true}; - if(specifier.startsWith('@shared/lib/'))return {url:pathToFileURL(join(root,'shared/lib',specifier.slice('@shared/lib/'.length)+'.ts')).href,shortCircuit:true}; + if(specifier.startsWith('@shared/'))return {url:pathToFileURL(join(root,'shared',specifier.slice('@shared/'.length)+'.ts')).href,shortCircuit:true}; + if(specifier.startsWith('.')&&context.parentURL?.startsWith(pathToFileURL(join(root,'shared')).href)){ + const path=resolve(dirname(fileURLToPath(context.parentURL)),specifier); + if(existsSync(path+'.ts'))return {url:pathToFileURL(path+'.ts').href,shortCircuit:true}; + } if(specifier==='../lib/secure-storage')return {url:pathToFileURL(join(app,'src/lib/secure-storage.ts')).href,shortCircuit:true}; if(['react','react/jsx-runtime','react-test-renderer'].includes(specifier))return {url:pathToFileURL(require.resolve(specifier)).href,shortCircuit:true}; return next(specifier,context); diff --git a/documents/runbooks/codeql.md b/documents/runbooks/codeql.md index a185a48b..59ad9d4d 100644 --- a/documents/runbooks/codeql.md +++ b/documents/runbooks/codeql.md @@ -1,5 +1,14 @@ # CodeQLの解析とAndroidビルド +## 共有受信者のCLIで鍵と輸送を分ける + +受信者のアカウント鍵包みを復元する処理は、私有鍵の能力だけを返す。 +HTTPクライアントと公開ID・期待文脈をその戻り値へ含めない。 +`RecipientSession`は鍵だけを保持し、輸送関数は呼出側で用意したSDKと +独立して検査した公開文脈を引数で受け取る。 +これにより、鍵包みからの機密フローを公開URLへ混ぜない設計にする。 +CodeQLの規則・解析対象・警告を抑制せず、修正ソースをCIで解析して確認する。 + `.github/workflows/codeql.yml` は6言語をそれぞれ解析します。 すべてのPR、mainへのpush、手動実行、週1回(日曜日03:17 UTC)で起動します。 matrixの1言語が失敗しても、ほかの言語の解析を続けます。 diff --git a/documents/spec/encrypted-cli.md b/documents/spec/encrypted-cli.md index ea9f9918..ced19b73 100644 --- a/documents/spec/encrypted-cli.md +++ b/documents/spec/encrypted-cli.md @@ -256,6 +256,11 @@ nextest・正式受入の成功ログと初回2つの失敗ログは、この子 ### Linuxと配布イメージの正式CLI検査 +受信者の復号能力は鍵だけを保持する。HTTPクライアント、公開ボックスID、 +独立して確認した共有文脈は呼出側に保持し、復号済みの能力から送信先や期待値を作らない。 +本人の鍵包みを復元した戻り値と公開輸送を分け、同じ認証・世代・固定期待値を維持する。 +平文の項目・名前・添付本文はSDKのHTTP要求には渡さない。 + 親の統合候補では、同じ `formal_cli_recipient` を正式Nodeと固定Dockerfileの配布イメージでも実行する。 `PASSSHARE_CLI_DOCKER_MODE` を `node` または `image` に明示し、既存の所有ラベル・固定PostgreSQLイメージ・ 所有者マーカーを持つ専用DBとネットワークを作る。Windowsの所有済みPGは、従来どおり絶対パスの From fee901ea17c605fea9cabed4b7f2f79c5bbd0504 Mon Sep 17 00:00:00 2001 From: mashfromband <matsumoto.yukuhashi@gmail.com> Date: Sun, 11 Oct 2026 19:39:29 +0900 Subject: [PATCH 10/10] fix(desktop): close cloud scope on native focus loss --- .../src/components/CloudItemsPanel.tsx | 37 ++++++++- .../spec/cross-client-encrypted-items-ui.md | 17 +++- frontend/tests/cross-client-cloud.test.mjs | 77 ++++++++++++++++++- 3 files changed, 125 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/components/CloudItemsPanel.tsx b/apps/desktop/src/components/CloudItemsPanel.tsx index 03fcb362..b7ff6214 100644 --- a/apps/desktop/src/components/CloudItemsPanel.tsx +++ b/apps/desktop/src/components/CloudItemsPanel.tsx @@ -4,6 +4,7 @@ import { useI18n } from "@shared/i18n/I18nProvider"; import { CloudBoxPanel } from "@shared/cloud/CloudBoxPanel"; import { createCloudSession, type CloudSession } from "@shared/cloud/session-transport"; import { CloudApiError } from "@shared/cloud/cloud-api"; +import { getCurrentWindow } from "@tauri-apps/api/window"; import { useDesktopLabels } from "../hooks/useDesktopLabels"; // Desktop uses the existing token-session login response (mobile transport). @@ -16,6 +17,8 @@ export function CloudItemsPanel() { const [boxId, setBoxId] = useState(""); const [busy, setBusy] = useState(false); const [failed, setFailed] = useState(false); + const [focused, setFocused] = useState(false); + const nativeFocused = useRef(false); const lifetime = useRef(0); const alive = useRef(true); const binding = useRef<CloudSession | null>(null); @@ -31,11 +34,39 @@ export function CloudItemsPanel() { } useLayoutEffect(() => { alive.current = true; + let disposed = false, focusRevision = 0; + const stops: Array<() => void> = []; + const acceptFocus = (value: boolean) => { + if (disposed) return; + nativeFocused.current = value; setFocused(value); + if (!value) window.dispatchEvent(new Event("passshare:cloud-lock")); + }; + // Native blur can leave the WebView document visible. Subscribe before the + // initial snapshot, and never let a delayed snapshot overwrite an event. + void (async () => { + try { + const nativeWindow = getCurrentWindow(); + for (const [event, value] of [["tauri://blur", false], ["tauri://focus", true]] as const) { + const stop = await nativeWindow.listen(event, () => { ++focusRevision; acceptFocus(value); }); + if (disposed) { stop(); return; } + stops.push(stop); + } + const revision = focusRevision; + const value = await nativeWindow.isFocused(); + if (revision === focusRevision) acceptFocus(value); + } catch { + for (const stop of stops.splice(0)) stop(); + acceptFocus(false); + if (!disposed) setFailed(true); + } + })(); const background = () => { if (document.visibilityState === "hidden") clear(); }; window.addEventListener("pagehide", clear); window.addEventListener("passshare:cloud-lock", clear); document.addEventListener("visibilitychange", background); return () => { + disposed = true; nativeFocused.current = false; + for (const stop of stops.splice(0)) stop(); alive.current = false; clear(); window.removeEventListener("pagehide", clear); window.removeEventListener("passshare:cloud-lock", clear); @@ -44,10 +75,10 @@ export function CloudItemsPanel() { }, []); async function login(event: FormEvent) { event.preventDefault(); - if (busy) return; + if (busy || !nativeFocused.current) return; const generation = ++lifetime.current; const controller = new AbortController(); request.current = controller; - const current = () => alive.current && generation === lifetime.current && !controller.signal.aborted && document.visibilityState !== "hidden"; + const current = () => alive.current && nativeFocused.current && generation === lifetime.current && !controller.signal.aborted && document.visibilityState !== "hidden"; let body = JSON.stringify({ email: email.current?.value, password: password.current?.value }); if (password.current) password.current.value = ""; setBusy(true); setFailed(false); @@ -86,7 +117,7 @@ export function CloudItemsPanel() { <label className="ps-label">{labels.apiUrl}<input className="ps-input" ref={base} type="url" required defaultValue={import.meta.env.VITE_API_BASE_URL ?? ""} disabled={busy} /></label> <label className="ps-label">{labels.email}<input className="ps-input" ref={email} type="email" required autoComplete="username" autoCapitalize="none" spellCheck={false} disabled={busy} /></label> <label className="ps-label">{labels.password}<input className="ps-input" ref={password} type="password" required autoComplete="current-password" disabled={busy} /></label> - <button className="ps-button" disabled={busy}>{t("cloud.openSubmit")}</button> + <button className="ps-button" disabled={busy || !focused}>{t("cloud.openSubmit")}</button> </form> : <> <label className="ps-label">{labels.vaultId}<select className="ps-input" value={boxId} onChange={(event) => setBoxId(event.target.value)}> {boxes.map((box) => <option key={box.id} value={box.id}>{box.name}</option>)} diff --git a/documents/spec/cross-client-encrypted-items-ui.md b/documents/spec/cross-client-encrypted-items-ui.md index 041ea2ab..8a719dd8 100644 --- a/documents/spec/cross-client-encrypted-items-ui.md +++ b/documents/spec/cross-client-encrypted-items-ui.md @@ -45,6 +45,15 @@ Desktopは広い画面で一覧と編集を並べ、Mobileと拡張は1列にす Desktopの`.pbox`、緊急キット復旧、保存失敗保持は既存経路を保つ。 クラウド画面へ明示的に切り替えるときは既存のnative lockコマンドで開いていた`.pbox`を閉じ、 そのマスターパスワードと下書きを別アカウントの背後へ残さない。保存ファイルを書き換える処理は追加しない。 +通常のクラウド画面では復旧用のnative lock通知だけに依存しない。 +Tauriの現在のwindowへ `tauri://blur` / `tauri://focus` を接続し、失焦時はdocumentがvisibleでも +共通のcloud-lockでsession・worker・鍵・下書きを閉じる。再フォーカスで旧状態を復元しない。 +通知接続と初回focus確認までログインを開始しない。遅い初回確認は新しい通知を上書きせず、 +接続失敗では既に登録した通知を解除して閉じる。unmount後の遅い登録も直ちに解除する。 +native暗号・認可や既存`.pbox`の失焦処理は変更しない。既存のcore権限で利用できるAPIだけを使う。 +ファイル選択・生体認証などアプリ所有のdialogがnative失焦を出すかは未確認。 +親の実WebView受入では通常の背景移行とdialogを区別し、添付追加が利用できることを確認する。 +dialogも失焦を出す環境ではこの接続は取消として閉じるため、無条件に添付操作の実受入成功とは扱わない。 旧cloud-syncと1PUX取込は、平文を読む/送る前に互換モードを要求する。 暗号化モードを旧同期へ流さない。暗号化ボックスと`.pbox`の自動同期はこの接続の対象外。 @@ -72,7 +81,7 @@ TauriとMV3のscript-srcには `'self' 'wasm-unsafe-eval'`、worker-srcには `' 2026-10-11に、Web・3クライアントのViteビルド、型検査、10言語481キーが成功。 3クライアント×3個の同梱アセットすべてで正式manifestとSHA256が一致。 -共通項目・添付25件、新しいsession/画面寿命・背景探索30件、既存Desktop52件・Mobile18件・拡張41件が成功。 +共通項目・添付25件、新しいsession/画面寿命・背景探索35件、既存Desktop52件・Mobile18件・拡張41件が成功。 新しい検査は別セッションの同時通信、epoch変更後の遅い応答、dispose/abort、実HTTP redirect非追従、 401/403/409/404、互換からの暗号化切替、解除中のlock/背景/unmount、受信者の独立pin経路を扱う。 項目・添付の検査は全名前・空ファイル・259byteの全bytes、409/応答喪失、取消・遅いFile読取りを扱う。 @@ -93,7 +102,11 @@ CUAでのタブ取得は37秒で時間切れとなった。画面配置・320px フラグ無効環境の互換経路は既存Webのmetadata確認と同じ条件へ合わせ、添付の全名前も直接照合した。 追加差分b1064dcのGPT-5.5レビューで、通信失敗のmetadata更新でも下書きを閉じるMust 1件を受領した。 失効401/403・世代不一致の場合だけ閉じ、通信0/502/503は同じworkerと編集画面を保持するよう直した。 -対応する3検査とDesktopの明示的な`.pbox`終了を追加し、修正差分を再レビューする。 +対応する3検査とDesktopの明示的な`.pbox`終了を追加し、固定0c40556の修正差分レビューはMust/Suggestion/Nit 0で成功した。 +その後Desktopのnative失焦接続を追加した。制御した通知/Reactでvisible失焦、遅い初回focus確認、 +遅いlogin、途中の通知登録失敗、unmount後の遅い登録の5検査が成功した。 +検査用ホストは最初にViteのimport.meta.env構文で失敗し、環境置換を追加して成功した。 +これらの検査は実OSのdialog・背景動作の成功には数えない。追加差分を独立レビューする。 最新の統合ソースに対するroot全workspace/doctest、全CI、正式Node/PGとの3クライアント往復、 MV3実popupのCSPと320px、Tauri/native WebViewの実保存/背景/再起動、全クライアントの復旧・失効・移行は親が正式に確認する。 親C1 fixtureと親PGは操作していない。新DB/アカウントは作成していない。 diff --git a/frontend/tests/cross-client-cloud.test.mjs b/frontend/tests/cross-client-cloud.test.mjs index a12cf434..529da1a7 100644 --- a/frontend/tests/cross-client-cloud.test.mjs +++ b/frontend/tests/cross-client-cloud.test.mjs @@ -12,7 +12,7 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true; const root = new URL('../../shared/cloud/', import.meta.url); function load(name, imports = {}, globals = {}) { const exports = {}; - vm.runInNewContext(ts.transpileModule(fs.readFileSync(new URL(name, root), 'utf8'), { + vm.runInNewContext(ts.transpileModule(fs.readFileSync(new URL(name, root), 'utf8').replaceAll('import.meta.env', '({})'), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX }, }).outputText, { exports, require: (id) => { if (!(id in imports)) throw new Error(`Unmapped import ${id}`); @@ -346,3 +346,78 @@ test('desktop cloud entry locks an open .pbox before another account may be show assert.equal(JSON.stringify(tree.toJSON()).includes('local-locked'), true); renderer.act(() => tree.unmount()); }); + +function desktopCloudHost({ heldSnapshot = false, heldLogin = false, failFocusListen = false, heldListen = false } = {}) { + const window = events(), document = { ...events(), visibilityState: 'visible' }, native = new Map(); + const inputs = [{ value: 'https://example.invalid' }, { value: 'synthetic@example.invalid' }, { value: 'synthetic-login' }]; + let tree, inputIndex = 0, releaseSnapshot, releaseLogin, releaseListen, sends = 0, disposed = 0, stops = 0, signal; + const { CloudItemsPanel } = load('../../apps/desktop/src/components/CloudItemsPanel.tsx', { + react: React, 'react/jsx-runtime': requireApp('react/jsx-runtime'), + '@shared/i18n/I18nProvider': { useI18n: () => ({ t: (key) => key }) }, + '../hooks/useDesktopLabels': { useDesktopLabels: () => ({ sync: {} }) }, + '@shared/cloud/cloud-api': cloudApi, + '@passshare/connect': { ConnectClient: class { async listVaults() { return [{ id: box, name: 'Synthetic' }]; } } }, + '@shared/cloud/session-transport': { createCloudSession: (_url, user) => ({ userId: user, dispose() { if (user) disposed++; } }) }, + '@shared/cloud/CloudBoxPanel': { CloudBoxPanel: () => React.createElement('output', null, 'cloud-secret-screen') }, + '@tauri-apps/api/window': { getCurrentWindow: () => ({ + async listen(name, callback) { + if (failFocusListen && name === 'tauri://focus') throw new Error('synthetic listen failure'); + if (heldListen) await new Promise((resolve) => { releaseListen = resolve; }); + native.set(name, callback); + return () => { native.delete(name); stops++; }; + }, + isFocused: () => heldSnapshot ? new Promise((resolve) => { releaseSnapshot = resolve; }) : Promise.resolve(true), + }) }, + }, { window, document, fetch: async (_url, init) => { + sends++; signal = init.signal; + if (heldLogin) await new Promise((resolve) => { releaseLogin = resolve; }); + return new Response(JSON.stringify({ user: { id: owner }, refreshToken: 'synthetic-session', connectAccessToken: 'synthetic-connect' })); + } }); + renderer.act(() => { tree = renderer.create(React.createElement(CloudItemsPanel), { createNodeMock: (element) => element.type === 'input' ? inputs[inputIndex++] : null }); }); + return { tree, window, document, inputs, native, + get sends() { return sends; }, get disposed() { return disposed; }, get stops() { return stops; }, get signal() { return signal; }, + json: () => JSON.stringify(tree.toJSON()), + async submit() { renderer.act(() => tree.root.findByType('form').props.onSubmit({ preventDefault() {} })); await flush(); }, + async focus(value) { renderer.act(() => native.get(value ? 'tauri://focus' : 'tauri://blur')?.({})); await flush(); }, + async snapshot(value) { releaseSnapshot(value); await flush(); }, async loginReply() { releaseLogin(); await flush(); }, + async listenReply() { releaseListen(); await flush(); }, + async unmount() { renderer.act(() => tree.unmount()); await flush(); }, + }; +} + +test('desktop native blur closes a cloud session even while the WebView stays visible', async () => { + const h = desktopCloudHost(); await flush(); await h.submit(); + assert.equal(h.sends, 1); assert.equal(h.json().includes('cloud-secret-screen'), true); + await h.focus(false); + assert.equal(h.document.visibilityState, 'visible'); assert.equal(h.disposed, 1); + assert.equal(h.inputs[2].value, ''); assert.equal(h.json().includes('cloud-secret-screen'), false); + await h.focus(true); assert.equal(h.json().includes('cloud-secret-screen'), false); + await h.unmount(); assert.equal(h.stops, 2); assert.equal(h.window.count(), 0); +}); + +test('desktop delayed initial focus snapshot cannot override native blur or allow login', async () => { + const h = desktopCloudHost({ heldSnapshot: true }); await flush(); + await h.submit(); assert.equal(h.sends, 0); + await h.focus(false); await h.snapshot(true); await h.submit(); assert.equal(h.sends, 0); + assert.equal(h.tree.root.findByType('button').props.disabled, true); + await h.focus(true); await h.submit(); assert.equal(h.sends, 1); await h.unmount(); +}); + +test('desktop late login after native blur cannot restore an account or item screen', async () => { + const h = desktopCloudHost({ heldLogin: true }); await flush(); await h.submit(); + await h.focus(false); assert.equal(h.signal.aborted, true); + await h.focus(true); await h.loginReply(); + assert.equal(h.disposed, 0); assert.equal(h.json().includes('cloud-secret-screen'), false); + await h.unmount(); +}); + +test('desktop partial native listener failure detaches the installed listener and prevents login', async () => { + const h = desktopCloudHost({ failFocusListen: true }); await flush(); await h.submit(); + assert.equal(h.sends, 0); assert.equal(h.native.size, 0); assert.equal(h.stops, 1); + assert.equal(h.json().includes('cloud.failed'), true); await h.unmount(); +}); + +test('desktop pending listener registration after unmount detaches without querying or installing another listener', async () => { + const h = desktopCloudHost({ heldListen: true }); await flush(); await h.unmount(); await h.listenReply(); + assert.equal(h.native.size, 0); assert.equal(h.stops, 1); assert.equal(h.sends, 0); +});