diff --git a/.github/renovate.json5 b/.github/renovate.json5 index f79d7ada5..aa6a6068c 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -77,7 +77,14 @@ }, { matchManagers: ["cargo"], - matchPackageNames: ["keepass"], + matchPackageNames: [ + "aes-gcm", + "crypto-bigint", + "hkdf", + "icu_normalizer", + "keepass", + "pbkdf2", + ], commitMessagePrefix: "[deps] Tools:", reviewers: ["team:team-tools-dev"], }, diff --git a/.prettierignore b/.prettierignore index e571f0bbc..11f84c1ca 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,6 +9,7 @@ crates/bitwarden-uniffi/swift/* # Test fixtures crates/bitwarden-exporters/resources/* +crates/bitwarden-importers/src/importers/onepassword/access/fixtures/* # CI output clippy_result.sarif diff --git a/Cargo.lock b/Cargo.lock index c5eb55f8c..f2647d1c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -912,24 +912,41 @@ dependencies = [ name = "bitwarden-importers" version = "3.0.0" dependencies = [ + "aes-gcm 0.11.0", + "async-trait", "bitwarden-api-api", + "bitwarden-api-base", "bitwarden-collections", "bitwarden-core", "bitwarden-crypto", "bitwarden-error", "bitwarden-exporters", + "bitwarden-random", "bitwarden-vault", "chrono", + "crypto-bigint 0.7.5", "data-encoding", + "hkdf 0.13.0", + "hmac 0.13.0", + "icu_normalizer", "keepass", + "pbkdf2", + "rand 0.10.2", + "reqwest", + "rsa", "serde", + "serde_json", + "sha1", + "sha2 0.11.0", "thiserror 2.0.19", "tokio", "tsify", "uniffi", + "url", "uuid", "wasm-bindgen", "wasm-bindgen-futures", + "wiremock", "zeroize", ] @@ -2302,7 +2319,8 @@ dependencies = [ [[package]] name = "crypto-bigint" version = "0.7.5" -source = "git+https://github.com/RustCrypto/crypto-bigint?tag=v0.7.5#2b54d248cce00457e3afb5650d9b14632ef4a116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" dependencies = [ "cpubits", "ctutils", @@ -3617,6 +3635,7 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", + "utf8_iter", "zerovec", ] diff --git a/Cargo.toml b/Cargo.toml index 5036e349e..2852f4a28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ keywords = ["bitwarden"] [workspace.dependencies] # External crates that are expected to maintain a consistent version across all crates +aes-gcm = { version = "0.11.0", features = ["zeroize"] } async-trait = ">=0.1.80, <0.2" bitwarden-api-api = { path = "crates/bitwarden-api-api", version = "=3.0.0" } bitwarden-api-base = { path = "crates/bitwarden-api-base", version = "=3.0.0" } @@ -77,15 +78,22 @@ chrono = { version = ">=0.4.26, <0.5", features = [ "std", ], default-features = false } ciborium = ">=0.2.2, <0.3" +crypto-bigint = { version = "0.7.5", features = ["alloc"], default-features = false } data-encoding = ">=2.0, <3" ed25519-dalek = { version = "3.0.0-pre.7" } futures = ">=0.3.31, <0.4" +hkdf = "0.13.0" hmac = "0.13.0" http = ">=1.4.0, <2.0" +icu_normalizer = { version = "2.2.0", features = [ + "compiled_data", + "utf8_iter", +], default-features = false } js-sys = { version = ">=0.3.72, <0.4" } keepass = { version = ">=0.13.7, <0.14", default-features = false } mockall = { version = ">=0.13.1, <0.16" } password-rules-parser = ">=1.1.0, <2" +pbkdf2 = { version = "0.13.0", default-features = false } proc-macro2 = ">=1.0.89, <2" quote = ">=1.0.37, <2" rand = ">=0.10.0, <0.11" @@ -205,9 +213,3 @@ opt-level = 3 # Stripping the binary reduces the size by ~30%, but the stacktraces won't be usable anymore. # This is fine as long as we don't have any unhandled panics, but let's keep it disabled for now # strip = true - -# crypto-bigint 0.7.0-0.7.4 were yanked and 0.7.5 has not yet propagated to the sparse index, -# so `rsa`/`ssh-key` (which require `^0.7`) cannot be resolved from crates.io. Pin to the 0.7.5 -# tag until the registry index catches up, then remove this patch. -[patch.crates-io] -crypto-bigint = { git = "https://github.com/RustCrypto/crypto-bigint", tag = "v0.7.5" } diff --git a/crates/bitwarden-importers/Cargo.toml b/crates/bitwarden-importers/Cargo.toml index 01d916699..ef83e0dd2 100644 --- a/crates/bitwarden-importers/Cargo.toml +++ b/crates/bitwarden-importers/Cargo.toml @@ -3,6 +3,7 @@ name = "bitwarden-importers" description = """ Internal crate for the bitwarden crate. Do not use. """ +exclude = ["**/fixtures"] version.workspace = true authors.workspace = true @@ -15,6 +16,10 @@ license = "GPL-3.0-only OR LicenseRef-Bitwarden-SDK" keywords.workspace = true [features] +# Re-exports the 1Password access module so the out-of-tree CLI can drive it against a real +# account. Never enable in production builds. +# TODO: Remove once the importer consumes the module directly. +test-utils = [] uniffi = [ "dep:uniffi", "bitwarden-core/uniffi", @@ -31,29 +36,46 @@ wasm = [ ] [dependencies] +aes-gcm = { workspace = true } +async-trait = { workspace = true } bitwarden-api-api = { workspace = true } bitwarden-collections = { workspace = true } bitwarden-core = { workspace = true } bitwarden-crypto = { workspace = true } bitwarden-error = { workspace = true } bitwarden-exporters = { workspace = true } +bitwarden-random = { workspace = true } bitwarden-vault = { workspace = true } chrono = { workspace = true, features = ["std"] } +crypto-bigint = { workspace = true } data-encoding = { workspace = true } +hkdf = { workspace = true } +hmac = { workspace = true } +icu_normalizer = { workspace = true } keepass = { workspace = true } +pbkdf2 = { workspace = true } +rand = { workspace = true } +reqwest = { workspace = true } +rsa = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } +sha1 = { workspace = true } +sha2 = { workspace = true } thiserror = { workspace = true } tsify = { workspace = true, optional = true } uniffi = { workspace = true, optional = true } +url = { workspace = true } uuid = { workspace = true } wasm-bindgen = { workspace = true, optional = true } wasm-bindgen-futures = { workspace = true, optional = true } zeroize = { workspace = true } [dev-dependencies] +bitwarden-api-base = { workspace = true } bitwarden-core = { workspace = true, features = ["internal", "test-fixtures"] } keepass = { workspace = true, features = ["save_kdbx4"] } tokio = { workspace = true, features = ["rt"] } +wiremock = { workspace = true } [lints] workspace = true diff --git a/crates/bitwarden-importers/src/importers/mod.rs b/crates/bitwarden-importers/src/importers/mod.rs index 38deb775d..fef6c9be6 100644 --- a/crates/bitwarden-importers/src/importers/mod.rs +++ b/crates/bitwarden-importers/src/importers/mod.rs @@ -2,3 +2,4 @@ //! [`crate::pipeline::ParsedImport`]; the generic pipeline encrypts and submits it. pub(crate) mod kdbx; +pub(crate) mod onepassword; diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/README.md b/crates/bitwarden-importers/src/importers/onepassword/access/README.md new file mode 100644 index 000000000..741cf7e86 --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/README.md @@ -0,0 +1,38 @@ +# 1Password access module + +Read access to a 1Password account. Logging in needs the username, master password and Secret Key, +plus a TOTP passcode when the account has 2FA. Once authenticated it downloads and decrypts every +accessible vault into a native 1Password model. + +A Rust port of the OnePassword module in Bitwarden's C# `password-manager-access` library. + +The 1P and BW name things differently. 1P has vaults that are independent, could be shared +separately, could have different access rights, encrypted with different keys. They will be imported +into Bitwarden collections. 1P doesn't have folders, only tags. + +## Notes + +- Supports TOTP 2FA only ATM +- No SSO support +- No service account support (they are not so good for export/import) +- One entry point, `Client::download_all_vaults`. No vault selection, no random access +- Added `aes-gcm`, `hkdf`, `pbkdf2`, `crypto-bigint` and `icu_normalizer` to the workspace, will + increase the wasm size. `crypto-bigint` is the exception, `rsa` and `ssh-key` already pull it in +- SRP uses `crypto-bigint` rather than `num-bigint` for the constant-time `modpow` +- Uses RustCrypto directly rather than `bitwarden-crypto`, which keeps HKDF, AES-GCM and RSA-OAEP + private and has no PBKDF2-SHA512 +- `icu_normalizer` only NFC-normalizes the password before PBKDF2. Heavy for one call, + `unicode-normalization` would be smaller +- The client fingerprint lives in `identity.rs`: app version, HTTP library and per-platform strings. + Question: do we need per-platform impersonation, or is one fixed identity enough? +- There are many tests converted from the C# repo, they became very noisy in Rust. Do we even need + them? See start_registers_an_unknown_device_then_retries for an example. +- Do we need to import password history? +- Only the credentials and the keys are zeroed. The decrypted vault data is not +- The server is never authenticated, `verify_key` does not recompute `serverVerifyHash` +- The wire DTOs derive `Debug`, so a debug log of one would print secrets +- Credentials are not trimmed, a pasted Secret Key with a trailing newline fails on length +- The sign-in domain is taken as a raw string and never validated +- A vault we hold no key for is skipped silently, and one undecryptable item aborts the whole import +- The module is under a blanket `allow(dead_code, unused_imports)` until the conversion layer lands +- Only the item DTOs in `wire` are public; the auth and session ones are `pub(super)` diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/account_key.rs b/crates/bitwarden-importers/src/importers/onepassword/access/account_key.rs new file mode 100644 index 000000000..349f4a95d --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/account_key.rs @@ -0,0 +1,179 @@ +//! Account Key (Secret Key): A2/A3 parse, HKDF-SHA256 hash, and XOR combine. + +use zeroize::Zeroize; + +use super::{error::OnePasswordError, kdf}; + +/// A parsed 1Password Account Key (also called the Secret Key), split into its format, uuid, and +/// key. +pub(super) struct AccountKey { + pub format: String, + pub uuid: String, + pub key: String, +} + +impl Drop for AccountKey { + fn drop(&mut self) { + self.key.zeroize(); + } +} + +impl AccountKey { + /// Parses a key string such as `A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9`. The string is uppercased + /// and its dashes removed before splitting into `format` (2), `uuid` (6), and `key` (the rest). + pub(super) fn parse(input: &str) -> Result { + let s = input.to_uppercase().replace('-', ""); + + let Some(format) = s.get(..2) else { + return Err(OnePasswordError::Internal(format!( + "invalid account key: too short, got {}", + s.len() + ))); + }; + + // Only A3 has ever been seen on a real account. A2 comes from reverse-engineered code and + // is untested against anything, so treat its 33-byte length as unverified. + match format { + "A2" if s.len() == 33 => {} + "A3" if s.len() == 34 => {} + "A2" => { + return Err(OnePasswordError::Internal(format!( + "invalid account key: 'A2' needs 33 characters without dashes, got {}", + s.len() + ))); + } + "A3" => { + return Err(OnePasswordError::Internal(format!( + "invalid account key: 'A3' needs 34 characters without dashes, got {}", + s.len() + ))); + } + _ => { + return Err(OnePasswordError::Internal(format!( + "invalid account key: unknown format '{format}'" + ))); + } + } + + let invalid = || OnePasswordError::Internal("invalid account key".into()); + Ok(AccountKey { + format: format.to_string(), + uuid: s.get(2..8).ok_or_else(invalid)?.to_string(), + key: s.get(8..).ok_or_else(invalid)?.to_string(), + }) + } + + /// `HKDF-SHA256(ikm = key, salt = uuid, info = format)`, 32 bytes. + pub(super) fn hash(&self) -> [u8; 32] { + kdf::hkdf_sha256(&self.format, self.key.as_bytes(), self.uuid.as_bytes()) + } + + /// XORs the hash with `bytes`, which must be exactly 32 bytes long. + pub(super) fn combine_with(&self, bytes: &[u8]) -> Result<[u8; 32], OnePasswordError> { + let mut h = self.hash(); + if h.len() != bytes.len() { + return Err(OnePasswordError::Internal( + "size doesn't match hash function".into(), + )); + } + + for (byte, other) in h.iter_mut().zip(bytes) { + *byte ^= other; + } + + Ok(h) + } +} + +#[cfg(test)] +mod tests { + use data_encoding::BASE64URL_NOPAD; + + use super::*; + + fn key() -> AccountKey { + AccountKey { + format: "A3".into(), + uuid: "RTN9SA".into(), + key: "DY9445Y5FF96X6E7B5GPFA95R9".into(), + } + } + + #[test] + fn parse_returns_parsed_format_a3_key() { + let key = AccountKey::parse("A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9").expect("valid key"); + assert_eq!(key.format, "A3"); + assert_eq!(key.uuid, "RTN9SA"); + assert_eq!(key.key, "DY9445Y5FF96X6E7B5GPFA95R9"); + } + + // Made up: no real A2 key was ever available to test against. + #[test] + fn parse_returns_parsed_format_a2_key() { + let key = AccountKey::parse("A2-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R").expect("valid key"); + assert_eq!(key.format, "A2"); + assert_eq!(key.uuid, "RTN9SA"); + assert_eq!(key.key, "DY9445Y5FF96X6E7B5GPFA95R"); + } + + #[test] + fn parse_throws_on_invalid_key_format() { + let cases = [ + "", + "A", + "A2", + "A3", + "A2-RTN9SA-DY9445Y5FF96X6E7B5GPFA95", + "A2-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9", + "A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R", + "A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R99", + "A3-RTN9SA-DY9445Y-FF96X6E7B-GPFA95R9", + ]; + for case in cases { + match AccountKey::parse(case) { + Ok(_) => panic!("expected {case:?} to be invalid"), + Err(err) => assert!( + err.to_string().contains("invalid account key"), + "unexpected error for {case:?}: {err}" + ), + } + } + } + + #[test] + fn hash_returns_hashed_key() { + assert_eq!( + BASE64URL_NOPAD.encode(&key().hash()), + "ZlI2kRote1dv7uflTenyIp5jBE0u-7Fl4aIiE0D9L-g" + ); + } + + #[test] + fn combine_with_returns_hashed_key() { + let combined = key() + .combine_with(b"All your base are belong to us!!") + .expect("32 byte input"); + assert_eq!( + BASE64URL_NOPAD.encode(&combined), + "Jz5asWNCDiVPjIaWKMmTUPtDZihClN8CwdZNMzWODsk" + ); + } + + #[test] + fn combine_with_throws_on_incorrect_length() { + let cases: [&[u8]; 5] = [ + b"", + b"A", + b"All your base are belong to us", + b"All your base are belong to us!", + b"All your base are belong to us!!!", + ]; + for case in cases { + let err = key().combine_with(case).expect_err("wrong length"); + assert!( + err.to_string().contains("hash function"), + "unexpected error: {err}" + ); + } + } +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/client.rs b/crates/bitwarden-importers/src/importers/onepassword/access/client.rs new file mode 100644 index 000000000..7d14f7070 --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/client.rs @@ -0,0 +1,372 @@ +//! The entry point: log in, unlock the account's keys, download its vaults. + +use super::{ + account_key::AccountKey, + credentials::Credentials, + device::ClientInfo, + error::OnePasswordError, + keychain::Keychain, + login::{self, LoginOutcome}, + model::{Item, ItemCategory, Vault}, + opdata::Encrypted, + rest::RestClient, + session::Session, + two_factor::TwoFactorUi, + wire::{ + AccountInfo, EncryptedEnvelope, KeysetsInfo, VaultAccess, VaultAttributes, VaultItem, + VaultItemsBatch, + }, +}; + +const PASSWORD_SK_METHOD: &str = "PASSWORD+SK"; +const MAX_OTP_ATTEMPTS: u32 = 3; +const ACCOUNT_INFO_ENDPOINT: &str = + "v1/account?attrs=billing,counts,groups,invite,me,settings,tier,user-flags,users,vaults"; +const KEYSETS_ENDPOINT: &str = "v1/account/keysets"; +const VAULT_ENDPOINT: &str = "v1/vault"; + +/// The 1Password client. Holds the injected HTTP transport so tests can point it at a mock host. +pub struct Client { + http: reqwest::Client, +} + +impl Client { + /// Creates a client over the given HTTP transport. The caller owns TLS configuration; in the + /// SDK that means `bitwarden_api_base::new_http_client()` or the client's own pooled instance. + pub fn new(http: reqwest::Client) -> Client { + Client { http } + } + + /// Logs in and downloads every vault the account can open, driving 2FA through `ui` when + /// required. + /// + /// An import takes the whole account, so there is no vault selection. + pub async fn download_all_vaults( + &self, + credentials: &Credentials, + ui: &dyn TwoFactorUi, + ) -> Result, OnePasswordError> { + let account_key = AccountKey::parse(&credentials.account_key)?; + let session = self.login(credentials, &account_key, ui).await?; + let (keychain, vaults) = unlock(credentials, &account_key, &session).await?; + + let mut downloaded = Vec::with_capacity(vaults.len()); + for info in &vaults { + downloaded.push(Vault { + id: info.id.clone(), + name: info.name.clone(), + description: info.description.clone(), + items: download_vault_items(&info.id, &keychain, &session).await?, + }); + } + + Ok(downloaded) + } + + /// Runs the login sequence, retrying the whole thing when the server rejects a TOTP code. + /// + /// A rejected code makes 1Password invalidate the session, so a wrong code restarts from + /// scratch, up to three times. + async fn login( + &self, + credentials: &Credentials, + account_key: &AccountKey, + ui: &dyn TwoFactorUi, + ) -> Result { + let client_info = ClientInfo::for_desktop(&credentials.device_uuid); + let rest = RestClient::new( + self.http.clone(), + format!("https://{}/api", credentials.domain), + &client_info.client_id(), + &client_info.user_agent, + &client_info.op_user_agent, + )?; + + // Confirm password + Secret Key login is available. This does not change between attempts. + let login_info = login::fetch_auth_methods(&credentials.username, &rest).await?; + if !login_info + .auth_methods + .iter() + .any(|m| m.kind == PASSWORD_SK_METHOD) + { + return Err(OnePasswordError::Unsupported(format!( + "no password login method found for account {}", + credentials.username + ))); + } + + for attempt in 0..MAX_OTP_ATTEMPTS { + match login::login_attempt(credentials, account_key, &client_info, attempt, ui, &rest) + .await? + { + LoginOutcome::Success(session) => return Ok(*session), + LoginOutcome::BadOtp => continue, + } + } + + Err(OnePasswordError::TwoFactorFailed) + } +} + +/// A vault the account can open, with its attributes already decrypted. +struct VaultInfo { + id: String, + name: String, + description: String, +} + +/// Decrypts the account keysets and every accessible vault key. +/// +/// The keychain is complete when this returns, so the download itself never adds to it. +async fn unlock( + credentials: &Credentials, + account_key: &AccountKey, + session: &Session, +) -> Result<(Keychain, Vec), OnePasswordError> { + // The vault list, and the keysets that unlock it. + let account_info: AccountInfo = session + .rest + .get_encrypted_json(ACCOUNT_INFO_ENDPOINT, &session.key) + .await?; + let keysets: KeysetsInfo = session + .rest + .get_encrypted_json(KEYSETS_ENDPOINT, &session.key) + .await?; + + // Everything else hangs off the master key, which only the credentials can produce. + let mut keychain = Keychain::new(); + keychain.decrypt_keysets( + &keysets.keysets, + &credentials.username, + &credentials.password, + account_key, + )?; + + // A vault whose key we do not hold is one the account can see but not open. + // TODO: Report skipped vaults and failed items instead of dropping them silently or failing the + // entire import. + let mut vaults = Vec::new(); + for vault in &account_info.vaults { + let Some(enc_key) = find_working_key(&vault.access, &keychain)? else { + continue; + }; + keychain.decrypt_aes_key(enc_key)?; + + let attributes: VaultAttributes = keychain.decrypt_json(&vault.enc_attrs)?; + vaults.push(VaultInfo { + id: vault.uuid.clone(), + name: attributes.name.unwrap_or_default(), + description: attributes.desc.unwrap_or_default(), + }); + } + + Ok((keychain, vaults)) +} + +/// Pages through a vault's items until `batchComplete`, parsing each supported item. +async fn download_vault_items( + vault_id: &str, + keychain: &Keychain, + session: &Session, +) -> Result, OnePasswordError> { + let mut items = Vec::new(); + let mut batch_id: i64 = 0; + loop { + let batch: VaultItemsBatch = session + .rest + .get_encrypted_json( + &format!("{VAULT_ENDPOINT}/{vault_id}/{batch_id}/items"), + &session.key, + ) + .await?; + + for item in batch.items.into_iter().flatten() { + if item.trashed == "Y" { + continue; + } + items.push(parse_item(&item, keychain)?); + } + + if batch.complete { + return Ok(items); + } + + // The batch id is a cursor, so an unchanged (or rewound) version would refetch the same + // page forever and duplicate its items. Nothing can make progress from here. + if batch.version <= batch_id { + return Err(OnePasswordError::Internal(format!( + "vault {vault_id} pagination stalled at content version {batch_id}" + ))); + } + batch_id = batch.version; + } +} + +/// Decrypts both payloads. Every category is kept, not only logins. +fn parse_item(item: &VaultItem, keychain: &Keychain) -> Result { + Ok(Item { + id: item.uuid.clone(), + category: ItemCategory::from_template_id(&item.template_uuid), + overview: keychain.decrypt_json(&item.enc_overview)?, + details: keychain.decrypt_json(&item.enc_details)?, + }) +} + +/// Finds a readable access entry whose vault key the keychain can already decrypt. +/// +/// `None` means every readable entry names a key we do not hold, which is a vault the account can +/// see but not open. A malformed envelope or an unsupported scheme is an error instead, so an +/// unreadable format never passes for a missing key. +fn find_working_key<'a>( + access: &'a [VaultAccess], + keychain: &Keychain, +) -> Result, OnePasswordError> { + for entry in access { + if is_read_accessible(entry.acl) { + let encrypted = Encrypted::parse(&entry.enc_vault_key)?; + if keychain.can_decrypt(&encrypted)? { + return Ok(Some(&entry.enc_vault_key)); + } + } + } + + Ok(None) +} + +/// Whether an ACL grants read access. +fn is_read_accessible(acl: i32) -> bool { + const HAVE_READ_ACCESS: i32 = 32; + acl & HAVE_READ_ACCESS != 0 +} + +#[cfg(test)] +mod tests { + use bitwarden_api_base::new_http_client; + use serde_json::json; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers}; + + use super::{ + super::opdata::{AesKey, decode64_loose}, + *, + }; + + const VAULT_ID: &str = "vault-id"; + + fn session(server: &MockServer) -> Session { + let rest = RestClient::new( + new_http_client(), + format!("http://{}/api", server.address()), + "client-id", + "user-agent", + "op-user-agent", + ) + .expect("valid headers"); + + Session::new(session_key(), rest) + } + + fn session_key() -> AesKey { + AesKey::new( + "SESSION", + decode64_loose("WyICHHlP5lPigZUGZYoivbJMqgHjSti86UKwdjCryYM").expect("valid key"), + ) + } + + /// Registers an encrypted items batch at `v1/vault/{VAULT_ID}/{batch_id}/items`. + async fn mock_batch(server: &MockServer, batch_id: i64, body: serde_json::Value) { + let envelope = session_key() + .encrypt(body.to_string().as_bytes(), &[0u8; 12]) + .expect("encrypts"); + server + .register( + Mock::given(matchers::path(format!( + "/api/v1/vault/{VAULT_ID}/{batch_id}/items" + ))) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::to_value(&envelope).expect("serializes")), + ) + .expect(1), + ) + .await; + } + + fn batch(version: i64, complete: bool) -> serde_json::Value { + json!({"contentVersion": version, "batchComplete": complete, "items": []}) + } + + #[tokio::test] + async fn download_pages_until_the_batch_is_complete() { + let server = MockServer::start().await; + mock_batch(&server, 0, batch(7, false)).await; + mock_batch(&server, 7, batch(9, true)).await; + + let items = download_vault_items(VAULT_ID, &Keychain::new(), &session(&server)) + .await + .expect("pagination advances to the final batch"); + + assert!(items.is_empty()); + server.verify().await; + } + + #[tokio::test] + async fn download_stops_when_pagination_does_not_advance() { + let server = MockServer::start().await; + mock_batch(&server, 0, batch(7, false)).await; + mock_batch(&server, 7, batch(7, false)).await; + + let error = download_vault_items(VAULT_ID, &Keychain::new(), &session(&server)) + .await + .expect_err("refetching the same page is an error, not a loop"); + + assert!( + error.to_string().contains("pagination stalled"), + "unexpected error: {error}" + ); + server.verify().await; + } + + fn access(acl: i32, kid: &str) -> VaultAccess { + serde_json::from_value(json!({ + "acl": acl, + "encVaultKey": {"kid": kid, "enc": "A256GCM", "cty": "b5+jwk+json", "data": ""}, + })) + .expect("valid access entry") + } + + #[test] + fn read_access_requires_the_read_bit() { + assert!(is_read_accessible(32)); + assert!(is_read_accessible(0xFFFF)); + assert!(!is_read_accessible(0)); + assert!(!is_read_accessible(31)); + } + + #[test] + fn find_working_key_skips_entries_we_cannot_use() { + let mut keychain = Keychain::new(); + keychain.add_aes(AesKey::new("usable", vec![0u8; 32])); + + let entries = vec![ + // Readable, but the key is not in the keychain. + access(32, "missing"), + // The key is in the keychain, but there is no read access. + access(1, "usable"), + // Both. + access(32, "usable"), + ]; + + let found = find_working_key(&entries, &keychain) + .expect("the schemes are all supported") + .expect("a usable entry"); + assert_eq!(found.kid, "usable"); + } + + #[test] + fn find_working_key_returns_nothing_without_a_usable_entry() { + let keychain = Keychain::new(); + let entries = [access(32, "missing")]; + let found = find_working_key(&entries, &keychain).expect("the scheme is supported"); + assert!(found.is_none()); + } +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/credentials.rs b/crates/bitwarden-importers/src/importers/onepassword/access/credentials.rs new file mode 100644 index 000000000..9a82afd25 --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/credentials.rs @@ -0,0 +1,28 @@ +//! The credentials a password login needs. + +use zeroize::Zeroize; + +/// The credentials for a password + Secret Key login. +/// +/// Deliberately not `Debug`: it holds the master password and Secret Key. +#[derive(Clone)] +pub struct Credentials { + /// The account's email address. + pub username: String, + /// The account's master password. + pub password: String, + /// The account's Secret Key (Account Key), such as `A3-XXXXXX-...`. + pub account_key: String, + /// The sign-in host: [`super::region::Region::domain`] for a standard region, or the + /// account's custom Enterprise domain. + pub domain: String, + /// The device id for this import. See `device::generate_device_uuid`. + pub device_uuid: String, +} + +impl Drop for Credentials { + fn drop(&mut self) { + self.password.zeroize(); + self.account_key.zeroize(); + } +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/device.rs b/crates/bitwarden-importers/src/importers/onepassword/access/device.rs new file mode 100644 index 000000000..7715e4ea7 --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/device.rs @@ -0,0 +1,209 @@ +//! The device identity presented to 1Password and its registration. + +use rand::Rng; +use serde_json::{Value, json}; + +use super::{ + error::OnePasswordError, + identity::{HTTP_LIB, PLATFORM, VERSION}, + rest::RestClient, + wire::SuccessStatus, +}; + +const BASE32_ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567"; +const DEVICE_UUID_LENGTH: usize = 26; +const DEVICE_ENDPOINT: &str = "v1/device"; + +/// Generates a 26-character 1Password device id from the lowercase base32 alphabet. +/// +/// A fresh id per import is expected: the login registers it with the account and nothing uses it +/// afterwards. +pub fn generate_device_uuid() -> String { + let mut rng = bitwarden_random::rng(); + (0..DEVICE_UUID_LENGTH) + .map(|_| BASE32_ALPHABET[(rng.next_u32() % 32) as usize] as char) + .collect() +} + +/// Client identity headers sent with every request. +pub(super) struct ClientInfo { + client_name: String, + client_version: String, + pub user_agent: String, + pub op_user_agent: String, + pub device_uuid: String, +} + +impl ClientInfo { + /// Impersonates the 1Password desktop client for the current platform. + pub(super) fn for_desktop(device_uuid: &str) -> ClientInfo { + let platform = PLATFORM; + + ClientInfo { + client_name: format!("1Password for {}", platform.os), + client_version: VERSION.to_string(), + user_agent: format!("1Password for {}/{VERSION}", platform.os), + op_user_agent: format!( + "1|{}|{VERSION}|{device_uuid}|||{HTTP_LIB}|{}", + platform.op_code, platform.os_suffix + ), + device_uuid: device_uuid.to_string(), + } + } + + pub(super) fn client_id(&self) -> String { + format!("{}/{}", self.client_name, self.client_version) + } + + /// The device descriptor sent to `v1/device` and inside `v2/auth/complete`. + /// + /// The real 1Password clients also send `model` and `osVersion`. The server accepted their + /// removal when this was tested, so they are left out, but add them back if it starts + /// rejecting the request. + pub(super) fn device_body(&self) -> Value { + json!({ + "uuid": self.device_uuid, + "clientName": self.client_name, + "clientVersion": self.client_version, + // Shown in the account's device list, so it names us rather than a 1Password client. + "name": "Bitwarden", + "osName": PLATFORM.os_name, + "userAgent": self.user_agent, + }) + } +} + +/// Registers the device with the server. +pub(super) async fn register_device( + client_info: &ClientInfo, + rest: &RestClient, +) -> Result<(), OnePasswordError> { + let response: SuccessStatus = rest + .post_json(DEVICE_ENDPOINT, client_info.device_body()) + .await?; + check_success(response, "register", client_info) +} + +/// Reauthorizes a previously deleted device. +pub(super) async fn reauthorize_device( + client_info: &ClientInfo, + rest: &RestClient, +) -> Result<(), OnePasswordError> { + let response: SuccessStatus = rest + .put(&format!( + "{DEVICE_ENDPOINT}/{}/reauthorize", + client_info.device_uuid + )) + .await?; + check_success(response, "reauthorize", client_info) +} + +fn check_success( + response: SuccessStatus, + action: &str, + client_info: &ClientInfo, +) -> Result<(), OnePasswordError> { + if response.success != 1 { + return Err(OnePasswordError::Internal(format!( + "failed to {action} the device '{}'", + client_info.device_uuid + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use bitwarden_api_base::new_http_client; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers}; + + use super::*; + + fn client(server: &MockServer) -> RestClient { + let info = ClientInfo::for_desktop("device-uuid"); + RestClient::new( + new_http_client(), + format!("http://{}/api", server.address()), + &info.client_id(), + &info.user_agent, + &info.op_user_agent, + ) + .expect("valid headers") + } + + #[test] + fn generated_uuid_has_expected_shape() { + let uuid = generate_device_uuid(); + assert_eq!(uuid.len(), DEVICE_UUID_LENGTH); + assert!(uuid.bytes().all(|b| BASE32_ALPHABET.contains(&b))); + assert_ne!(uuid, generate_device_uuid()); + } + + #[test] + fn client_info_builds_identity_headers() { + let info = ClientInfo::for_desktop("device-uuid"); + assert_eq!(info.client_id(), format!("{}/81210036", info.client_name)); + assert!(info.op_user_agent.contains("device-uuid")); + assert!(info.user_agent.starts_with("1Password for ")); + } + + #[test] + fn device_body_carries_the_device_descriptor() { + let info = ClientInfo::for_desktop("device-uuid"); + let body = info.device_body(); + assert_eq!(body["uuid"], "device-uuid"); + assert_eq!(body["clientVersion"], "81210036"); + assert_eq!(body["osName"], PLATFORM.os_name); + assert_eq!(body["clientName"], format!("1Password for {}", PLATFORM.os)); + } + + #[tokio::test] + async fn registers_and_reauthorizes_the_device() { + let server = MockServer::start().await; + server + .register( + Mock::given(matchers::path("/api/v1/device")) + .and(matchers::method("POST")) + .and(matchers::body_partial_json(json!({"uuid": "device-uuid"}))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"success": 1}))) + .expect(1), + ) + .await; + server + .register( + Mock::given(matchers::path("/api/v1/device/device-uuid/reauthorize")) + .and(matchers::method("PUT")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"success": 1}))) + .expect(1), + ) + .await; + + let rest = client(&server); + let info = ClientInfo::for_desktop("device-uuid"); + register_device(&info, &rest).await.expect("registers"); + reauthorize_device(&info, &rest) + .await + .expect("reauthorizes"); + + server.verify().await; + } + + #[tokio::test] + async fn reports_a_failed_registration() { + let server = MockServer::start().await; + server + .register( + Mock::given(matchers::path("/api/v1/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"success": 0}))) + .expect(1), + ) + .await; + + let error = register_device(&ClientInfo::for_desktop("device-uuid"), &client(&server)) + .await + .expect_err("registration is rejected"); + + assert!(error.to_string().contains("failed to register the device")); + server.verify().await; + } +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/error.rs b/crates/bitwarden-importers/src/importers/onepassword/access/error.rs new file mode 100644 index 000000000..4fb807ce8 --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/error.rs @@ -0,0 +1,66 @@ +//! Error type for the 1Password client. +//! +//! The codes noted below are 1Password server error codes. + +use thiserror::Error; + +/// Errors returned by the 1Password access library. +#[derive(Debug, Error)] +pub enum OnePasswordError { + /// Network or transport failure. + #[error("network error: {0}")] + Network(String), + + /// Invalid username, password, or Secret Key (1Password code 102). + #[error("invalid credentials")] + BadCredentials, + + /// The requested resource was not found (1Password code 117). + #[error("not found")] + NotFound, + + /// The account requires two-factor authentication to continue. + #[error("two-factor authentication required")] + TwoFactorRequired, + + /// A submitted two-factor code was rejected. + #[error("two-factor authentication failed")] + TwoFactorFailed, + + /// Decryption of a server payload failed. + #[error("decryption failed")] + Decryption, + + /// A server response could not be parsed. + #[error("failed to parse server response")] + Parse, + + /// An item, category, or auth method that is not supported yet. + #[error("unsupported: {0}")] + Unsupported(String), + + /// An invariant was violated: malformed input, a size mismatch, or a "should not happen" case. + #[error("internal error: {0}")] + Internal(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn errors_describe_themselves() { + assert_eq!( + OnePasswordError::Network("timed out".into()).to_string(), + "network error: timed out" + ); + assert_eq!( + OnePasswordError::Unsupported("Duo".into()).to_string(), + "unsupported: Duo" + ); + assert_eq!( + OnePasswordError::BadCredentials.to_string(), + "invalid credentials" + ); + } +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/fixtures/encrypted-aes-key.json b/crates/bitwarden-importers/src/importers/onepassword/access/fixtures/encrypted-aes-key.json new file mode 100644 index 000000000..2979c6817 --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/fixtures/encrypted-aes-key.json @@ -0,0 +1,7 @@ +{ + "cty": "b5+jwk+json", + "data": "6diyWpc9lhcSpL1lB1-FYFNgkrHQ_wsfoDA7gb2-oFzZTItL9QdxopxS80UhTOTyFhW2ubtZeD3YiyvflaG4_XfJ810MBCCFlFtBqE0clPp6YzQsOPuyWa7yPqIt8wIJdPfCMG9Tqy4KuCLCeZsSfqU1O9ccWRtAO3C9mPLJA_hpjbedOSbQ0D4kth9rfyKW8RlHSf-f6Fhhmy7_ObH_5NI9JZKu3hFXxA-FbOA", + "enc": "A256GCM", + "iv": "zd_oSihcQftkI3bu", + "kid": "mp" +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/fixtures/encrypted-rsa-key.json b/crates/bitwarden-importers/src/importers/onepassword/access/fixtures/encrypted-rsa-key.json new file mode 100644 index 000000000..cc03bd3c1 --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/fixtures/encrypted-rsa-key.json @@ -0,0 +1,7 @@ +{ + "cty": "b5+jwk+json", + "data": "qJHfQzPGWABpSpxj0me-gChdkeXsJ_gUYbgSxuDSQY4VVv75SqTJTgUrHZe2eR7dNOnG_kHZxZXLOL73izk39Fsjks4KfYsCYKRq24iuEycII7ln_BzTP1gn7j1wNTCNThXbgJJvjiImt37nwV8dIE1SqFFGh5vhlRwc8gp10gW9ulhc1wGo6NZIXq_qfgU0MMYmiSed0KRF45yNeDqTqZmXHnXx1JmFo9XzhNSkFrK3q7rNTiH0a1ElK43C5wwVHd6YNkOWwXC1_gGuNawcP4z-Vz_NI28SG8zbUKMg_0IYAje9qDO0uguAa_86v0G5p0Z4SkP3lLgl3g_V7X3Midzqho6RIe2XL4VtAAtnNCYi7j2v2u--4MMC-kqQHHNsoT3cSmREO-wwiCVaZqDEJEYyV-bA-q9w3VmkBNZY98lINJPHw2kjesgb8ICenceTRU7hLZgJK6EpZhSJl092wooLWh75YxGom9d_wa34G92111_4J95CJIVQzsKwgiYo-1O0HJ3OZ0oSlQYDRPB1fVFrvwZKj_LgjL3Zf2WtTMGTqXY7tswQkt7pfV2JHXAc2sRuQjSDYEq_MMck7cTX_WauygvCvsUYJgD0JxAmd6s00p-KbxzlXDP4uVzlqT-MIB1zK4hC6wujWpmUmf3qIxdnwYcWMaDNnZIfDfex8GIl5hossrPOBfEPdbDDKUPK9YRx6U3rVgpai5O3tDAScFde0Rjreuia4Yt2PeSXPkp1G_pC952lomFfIQeHMqI0HQi6o-GNSZBtVFXtUpoRm1i-OXR7ux7nhFrccc16NxkwRJjKg1hYO5f7atEAh8vAm_0hPZTWIaA_B3srIF_lb7bzJAgm5cH-_kKbMAjZWM1snA5w3_8xzLAMPL-pAguF-OISObwvOlTg3AhPmKP47jLVSKKe1w-Rf9xo9QvLMForpCSk53jXstT19W43jq9yulHbMwkjcjVXzR6vJ1XvNgpGRHsI-yqS3Ym2pKXImJwZkbHfJtzKfO-TW7MYpD-1Ray5e7paQvHRvGYultBE729PVNVVa0XLygxkpNVY74Z_KPDf4_AacEROhz_V-2dKj7pQhJnO7NKVqLlIcnE716Vi4POnjYD14hYHPY1K4dV1CjW5EWDjKRUDpR0vKGmLeUavFil8YRlNPiEVypCeyG5G0XzzlKuKZGzy_YzYHpogdWSa6YWNLr0aeGVmZy1S2bR8CmUCRj9ldrt7-9sT9av_lhpkI4HXvFoC4LmM5o5megZre0TuA6Kj-12UwL-HRY8O0BW1ZJUfRZ6F3-aEYlN6_9hJBzCeBt9GedW2UKDDdw6qrknnSFDbEn85lWK4oWyPbOW2--yn54UCrLsGQQthe1__CSK_HTwnM6x-T7TUd1rysi76TbEsXz4xqBkJzvP-kBZb5iPGGysfjE9uxRow9wRrxRHOiWsBNR8Geqo4WvZudrcVlvDCMj_iT0f5rwgK4YgqjQo95kRagH2pBzie3dXyeku_tmReOSo8UHVq9S-rNvmyA5HjWV3r5Bj6vr4_bUWw4dDDr3mDaKXGxuDY_fL_TNM3uANUx00Sh9BlSDMB0ku_4hbdEx-uhOopoy2uOiRt2omhNDueObWM1MtUGgNjQFfCib4ldyOOJuw2i0W5WEG0n4Op6p-P9Nt-E7THsl6fbmxvAaQ4bIS2upuOc5-8eMzFCjzPkkp3tSD8jpUewIh6BIIGEFXIjR0kY9oujqxTwCTq_j8hAmUuEpn8KRtCBvD9GGACiPVngVPwgQIKus5bTj3j110_7WQYe6sBp6UXVnOaF3N1MYb_b0WaGTesLqFZtqAwYliC7Xlob9AqctUHXvrrZo4myHCyzdCjVhq_1GeCOQbdWonfEGPLqj_6HV0bWLsvwQWY9ydeWN7R8Td9fZzXuHWWZJcammfPlXi2dYzMUF_Xdb2kmo4fNsblNbn6rLUBhS24ddOzWLpOAl_sljT3sd5ry-ekhHzoDZ-VgqnbFIcYTHii4T46EgeVI8MQz1R05S-6tP2PomWbxNBisPdZwOyOQ57HHAO_nUEtEIUuqetSB_K0bmazXuunqloCEauBWnT0GOIlWyG4SmyuvOdO5RmzYo97cE0jNhJwpsQ4-jtnrhl_b7I2vLBf-fDGDQ4FfG4VTV6M9SqEcPe8zDony1WV6KWQcQihpoOPdqaM1_LAmEdKoeqo2X8GFlvpuxbNRymJepbD4Ma0CtWkooGTho8pB0-vXaAn9JFsWzAQJ-absekGjcVSgXMNU1QHmAm_vaemZLY1Ab4x", + "enc": "A256GCM", + "iv": "gS-DVA13BigzuaUc", + "kid": "szerdhg2ww2ahjo4ilz57x7cce" +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/fixtures/get-keysets-response.json b/crates/bitwarden-importers/src/importers/onepassword/access/fixtures/get-keysets-response.json new file mode 100644 index 000000000..d76906ca0 --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/fixtures/get-keysets-response.json @@ -0,0 +1,180 @@ +{ + "version": 5, + "keysets": [ + { + "uuid": "szerdhg2ww2ahjo4ilz57x7cce", + "encryptedBy": "mp", + "sn": 1, + "encSymKey": + { + "alg": "PBES2g-HS256", + "cty": "b5+jwk+json", + "data": "6diyWpc9lhcSpL1lB1-FYFNgkrHQ_wsfoDA7gb2-oFzZTItL9QdxopxS80UhTOTyFhW2ubtZeD3YiyvflaG4_XfJ810MBCCFlFtBqE0clPp6YzQsOPuyWa7yPqIt8wIJdPfCMG9Tqy4KuCLCeZsSfqU1O9ccWRtAO3C9mPLJA_hpjbedOSbQ0D4kth9rfyKW8RlHSf-f6Fhhmy7_ObH_5NI9JZKu3hFXxA-FbOA", + "enc": "A256GCM", + "iv": "zd_oSihcQftkI3bu", + "kid": "mp", + "p2c": 100000, + "p2s": "i2enf0xq-XPKCFFf5UZqNQ" + }, + "encPriKey": + { + "cty": "b5+jwk+json", + "data": "qJHfQzPGWABpSpxj0me-gChdkeXsJ_gUYbgSxuDSQY4VVv75SqTJTgUrHZe2eR7dNOnG_kHZxZXLOL73izk39Fsjks4KfYsCYKRq24iuEycII7ln_BzTP1gn7j1wNTCNThXbgJJvjiImt37nwV8dIE1SqFFGh5vhlRwc8gp10gW9ulhc1wGo6NZIXq_qfgU0MMYmiSed0KRF45yNeDqTqZmXHnXx1JmFo9XzhNSkFrK3q7rNTiH0a1ElK43C5wwVHd6YNkOWwXC1_gGuNawcP4z-Vz_NI28SG8zbUKMg_0IYAje9qDO0uguAa_86v0G5p0Z4SkP3lLgl3g_V7X3Midzqho6RIe2XL4VtAAtnNCYi7j2v2u--4MMC-kqQHHNsoT3cSmREO-wwiCVaZqDEJEYyV-bA-q9w3VmkBNZY98lINJPHw2kjesgb8ICenceTRU7hLZgJK6EpZhSJl092wooLWh75YxGom9d_wa34G92111_4J95CJIVQzsKwgiYo-1O0HJ3OZ0oSlQYDRPB1fVFrvwZKj_LgjL3Zf2WtTMGTqXY7tswQkt7pfV2JHXAc2sRuQjSDYEq_MMck7cTX_WauygvCvsUYJgD0JxAmd6s00p-KbxzlXDP4uVzlqT-MIB1zK4hC6wujWpmUmf3qIxdnwYcWMaDNnZIfDfex8GIl5hossrPOBfEPdbDDKUPK9YRx6U3rVgpai5O3tDAScFde0Rjreuia4Yt2PeSXPkp1G_pC952lomFfIQeHMqI0HQi6o-GNSZBtVFXtUpoRm1i-OXR7ux7nhFrccc16NxkwRJjKg1hYO5f7atEAh8vAm_0hPZTWIaA_B3srIF_lb7bzJAgm5cH-_kKbMAjZWM1snA5w3_8xzLAMPL-pAguF-OISObwvOlTg3AhPmKP47jLVSKKe1w-Rf9xo9QvLMForpCSk53jXstT19W43jq9yulHbMwkjcjVXzR6vJ1XvNgpGRHsI-yqS3Ym2pKXImJwZkbHfJtzKfO-TW7MYpD-1Ray5e7paQvHRvGYultBE729PVNVVa0XLygxkpNVY74Z_KPDf4_AacEROhz_V-2dKj7pQhJnO7NKVqLlIcnE716Vi4POnjYD14hYHPY1K4dV1CjW5EWDjKRUDpR0vKGmLeUavFil8YRlNPiEVypCeyG5G0XzzlKuKZGzy_YzYHpogdWSa6YWNLr0aeGVmZy1S2bR8CmUCRj9ldrt7-9sT9av_lhpkI4HXvFoC4LmM5o5megZre0TuA6Kj-12UwL-HRY8O0BW1ZJUfRZ6F3-aEYlN6_9hJBzCeBt9GedW2UKDDdw6qrknnSFDbEn85lWK4oWyPbOW2--yn54UCrLsGQQthe1__CSK_HTwnM6x-T7TUd1rysi76TbEsXz4xqBkJzvP-kBZb5iPGGysfjE9uxRow9wRrxRHOiWsBNR8Geqo4WvZudrcVlvDCMj_iT0f5rwgK4YgqjQo95kRagH2pBzie3dXyeku_tmReOSo8UHVq9S-rNvmyA5HjWV3r5Bj6vr4_bUWw4dDDr3mDaKXGxuDY_fL_TNM3uANUx00Sh9BlSDMB0ku_4hbdEx-uhOopoy2uOiRt2omhNDueObWM1MtUGgNjQFfCib4ldyOOJuw2i0W5WEG0n4Op6p-P9Nt-E7THsl6fbmxvAaQ4bIS2upuOc5-8eMzFCjzPkkp3tSD8jpUewIh6BIIGEFXIjR0kY9oujqxTwCTq_j8hAmUuEpn8KRtCBvD9GGACiPVngVPwgQIKus5bTj3j110_7WQYe6sBp6UXVnOaF3N1MYb_b0WaGTesLqFZtqAwYliC7Xlob9AqctUHXvrrZo4myHCyzdCjVhq_1GeCOQbdWonfEGPLqj_6HV0bWLsvwQWY9ydeWN7R8Td9fZzXuHWWZJcammfPlXi2dYzMUF_Xdb2kmo4fNsblNbn6rLUBhS24ddOzWLpOAl_sljT3sd5ry-ekhHzoDZ-VgqnbFIcYTHii4T46EgeVI8MQz1R05S-6tP2PomWbxNBisPdZwOyOQ57HHAO_nUEtEIUuqetSB_K0bmazXuunqloCEauBWnT0GOIlWyG4SmyuvOdO5RmzYo97cE0jNhJwpsQ4-jtnrhl_b7I2vLBf-fDGDQ4FfG4VTV6M9SqEcPe8zDony1WV6KWQcQihpoOPdqaM1_LAmEdKoeqo2X8GFlvpuxbNRymJepbD4Ma0CtWkooGTho8pB0-vXaAn9JFsWzAQJ-absekGjcVSgXMNU1QHmAm_vaemZLY1Ab4x", + "enc": "A256GCM", + "iv": "gS-DVA13BigzuaUc", + "kid": "szerdhg2ww2ahjo4ilz57x7cce" + }, + "pubKey": + { + "alg": "RSA-OAEP", + "e": "AQAB", + "ext": true, + "key_ops": [ + "encrypt" + ], + "kid": "szerdhg2ww2ahjo4ilz57x7cce", + "kty": "RSA", + "n": "v1wkFPMC-1eAV46KP_46g8L2-eImJyybx8dMPXdAZJ04hd3dtq_3bbbjUXxT4X7NoCSIrGZHE5O1TTTiBX3zHLEoyT33s-ViLNJXUHfkF2vXQYcqHnBIyr9H-p08eki7A9Cn8K1kk2d4BPj7GtvPWYp11fmgdmPNN5jVJKRG3ggrnluA4DB_txNnTPOoql-l5JCacFwiGIdYH3oaHZ9jjGTVwaFIDTv39ttnrA5EXyEJOPxQK9S-3qk69LJShfSrmWuTU_QUZjCOiVOoRtzcHzCRcQjWzGvTLFe64qze03exMBUU8O4ACfGUjlu-UIiK0V6F6gamofZmmL5pHr7Gjw" + }, + "encSPriKey": null, + "spubKey": null, + "createdAt": "2016-08-04T13:05:41Z", + "updatedAt": "0001-01-01T00:00:00Z" + }, + { + "uuid": "sm5hkw3mxwdcwcgljf4kyplwea", + "encryptedBy": "", + "sn": 1, + "encSymKey": + { + "cty": "b5+jwk+json", + "data": "OgV4YjIF4ig2SSNXVCyJBnWvsUhRF8emibmKbC_dhJG8nxkh0tvZ94bYvsIPqPVwXJIO-qJCgRMN7tBulMn0YAhSfeqslV3bCyNBIqC4O982v8trICyP3y7GlWqhDnBQg5t4Xs9M-SFZo6ADb4Jrl2CuJ0K36uMQgXS-Gmi-JRg040fEaFpoD1BnqI310d46dcIbYo1P71Qd73ADEuIZj29Zjzhrh6Yl3srGsmfShf9M79d4vreB0DkeRXfxo6jhWz0wB8DJL9RdVs0AmvIgBw6NU19LtviNhyk_3qD_y6bqgO3qAmvshRzFDox3SO_EMVJ7Qa_hp6ORLqhZrm2_bQ", + "enc": "RSA-OAEP", + "kid": "szerdhg2ww2ahjo4ilz57x7cce" + }, + "encPriKey": + { + "cty": "b5+jwk+json", + "data": "uneA6pT3fhhBHt31KnlFeK6GPKZwNyvDvxDKwbRgOLLoCQUetslQJc0NsiRKUgVe5s0WEyOeZqw6VHtoLaSoJahQJ8DNAh64_VnH9IYa-nLi0SlPHDmznBaxZmchj_IgwYY-nudFRhWfLKpapoOLarRB9Ch-G-Y7x0wfq4CcvcQaEpH6xtjwwSNqflIM3p4TMUJKYXRm9SyFrgKrij6qG6HW9UOr3Nmr0w3xZa2XCOeGkcynrcFnq8no1rPMauaaSAatQP5siuUhHV4oC5xINbRKmYxhWBDnj59hrZ246Np7JYnPQ8_nmybD9osIIK1t-YMff_DKkFHEyazZ2rd9ZHCXr5C5xAzhJ0r7M_meaLz3op5dzZxMMVprGhzidB6QcQ2Kfd1uUJOUXYUaFCuDFBSiS90vrnUxdQ2veTWMBujlycauIGeCiyNdiCvXn9lrNo2HQIalRL1TPKf72C3t9waDpNUVOElLSWv0FsW-TcEsK81TsskU8Q2mUk4FyDjgIUs7Ktdy-0gYjRKd5JtSTX-yx_EXm_qjvctNtIQ_YQjQ3sPl3DQ6FL98acs7FKJVOPyUz8A2oJ05H5szFtqXR8WivlqnokWIO1ZUKitDFnjnAvroYnL3LAwjShnbnLj1hJIkNnaZdvH7SOIYxrtgdjyhkdAbXuyY7QZaN_z2Ow2HyBO_36iSDrJF4ege9hK35bjkAvXovX7es7jFpa9FtuG2vaTb1un4lF9qn8lSTJ9WUlEg9WphzFs2hsu9bolNlPWjYEqwgrjGaWe02BeKY6MoaBVXFVVklr2GogM3vxrmtwm0PToVVxxiRy192xvqMr_ypGIdpywp1GrK_zL_512C65lyAMH25LsHdLq2I2uboCAT8c9cFu0aKEYFIZOdUtP-kMfDmPBM8z7Amrp9k3_uSGcCY5cjxzyeW41zCdIo4ASDVnWQ8d9GK8a7pea6Z2FDjLeBIArLjAx3g85GULWJ0H-uwv169bezRfRPO8aaOdWbHDtAARnonj7BpDeUU-tx1YcXiJKeluDWRHgU4pcTOwfv5Js6HQY6ZBlqna-2yKwjNXeeXs7fNqSgC272RBIJ8KSWx4_r-rUy-gGCfi-qmlxExoYmKgWBqOFMlkpIQ4MmTQkJIFbDH4JBcMmiwYAalwRveR8f7vyuWe9dRpbVutzBiTPJP90cCkrjfiSfXZtlnRDaLqZUqCOFciTQ-HT2yN6VFQxmXqEFytaTAP9LWt7b9unwSZpjno0J9Hie0y7UuYrhIo_eJH4sDtnv5xRW2cVv91vcSnXEUGDbqUKmV9kfcS99GwZGdCg08MnaUO5_KtjVDVohS4wwUaajBWW4i_ESO9G6HksyGBj2MyVGU4m19MqMwQFKgsJht1-_FX2o8OsKPITQqFumxObRO3OZA2MaYy1_NsX0RR417JooncYrXgGC9tTTiOTEX209A_yFWYyaB9tElYcPu_uK6ce_I28_5ASifuV-Yrj2ylRG_lOHaZf7qsMmn_Jl0DAytk4lRpL8ZvSlMj_ZpwvBNZ5e9iYgZFUYULxPqgAGTYqt2ed8sWdqe8R-s0UXeKGlWF62KJfLX6aJ1pdBwKP2nZmt-eMxdjBc2rEBYItzNKenSP-cIQxjjTJHGbSaeZjUV8r_IA_TeP7z4iS7uDd1jdRNAJvSmk37KATAk6H1JmvoyXHqgg3rorAu6swYxngvDEEixvRFfPOjtDdC8Hi8l6BiOQ1DsAmTBRuNr0tuYz4gSY9pCl65IKlAJs28GhC0I4GuPniMz5NaVZeazJU51bw9FCL8U7ANjknINC8DRfJY4ULsQ84LukCJI0bdvVIYIhaqbcLSHwRT2MMZtTxjAc8hDRBFL5BsJyQa7cmg5_WfSFJv181tZiFPzXtIPhUWuVcfGFedXpoRHvlxyeAtzdvKwdyNDf82WnLWF-4nHwFkHyV9v3rBptYITdqXl9A7d0SNV2EXvRLIj42quz0yXd5prXJ4OUKpsik_vPyeIHyJxvkbiQbj5p_bi9n9lXi5ZiyLJBim5JaGdXitoauVJOVkmSTRYbTThg2hKgMzYCHBOlOyLhzPrvjQMiIvHiFEBcRwKL6k-9ViiQ5extZVlRpL2-CZy714CCz2rg59nvWmGWdaRwghtZ7aCkZrUHIb_fTDiSCmafm2XgQmjMu6agUoYyo-zizif2seKzjpKFtyrkGzQ8XJR8R2FQMhd_-_s_aFmOxks3MEt9x9cE4JK2bVX92Z2oZ7n23s_W5ta7OR1WvlH0YJfVWk9t92g6YHp5fV", + "enc": "A256GCM", + "iv": "PbIggyhIDapK8-wM", + "kid": "sm5hkw3mxwdcwcgljf4kyplwea" + }, + "pubKey": + { + "alg": "RSA-OAEP", + "e": "AQAB", + "ext": true, + "key_ops": [ + "encrypt" + ], + "kid": "sm5hkw3mxwdcwcgljf4kyplwea", + "kty": "RSA", + "n": "1tej9lIjZsFe3N_CbQSVpdxxl4BUVd-fKxfjMt5aN6OgtTb5J_TtJG1hqwtBVscudPydut_4IZQKeCPEWofabvNsYcsupoehLWf3X9i2AlmMk6UPeOCaHC4UFs-eto1MU8G5pKqoawEfxGwD7_QEFqeQ_AkXSX-ina8SqzSmCdpCPrSq_E-NoPFxA-5SQWm2O7DbBKuFFyyIlbFkPDVB4tZy3Mer3gtcoDRz4rMo2lKdSrOP6GhfiBX1yVNi8cYX5T9SHNFhNNxU29RhAat3xuoU7uqQs2HNzK9nHCPDpzNTi5GCZZXkSZ-MyYKn7bMTMP0SmXnW8WPL8UB3xjnwKQ" + }, + "encSPriKey": null, + "spubKey": null + }, + { + "uuid": "yf2ji37vkqdow7pnbo3y37b3lu", + "encryptedBy": "", + "sn": 1, + "encSymKey": + { + "cty": "b5+jwk+json", + "data": "kjdYcX9rup1gc6DgP4nrGNRNzD9RhY873daVx69LbYtfw5Imz45ff0djFMzlrLAj2SXJJpdF9sCiPoiappfkTLpN37XJzNXfruel4Z7NXB3OvdJsGfES7O3LhNlBgrGYWo0RT3hXAEaFya2FhS_aQ3RbG0yTdZTxm56d0ImKmQeYjw5rATm6OKb3XsjFK5_V1R_V4tyRUHckRF6ii8rXqu6EuZMLIYtfe7vqXPLTbpq2oWT5ZFHbn9dxy0pmmCKofJOimggNxB0aflZQDaRom6vauswvliZ_toFzl0phFCQw_5ENBWD-5h0EmxZ90PpVcXgq2NJJU9EVk8ayRcHOPQ", + "enc": "RSA-OAEP", + "kid": "szerdhg2ww2ahjo4ilz57x7cce" + }, + "encPriKey": + { + "cty": "b5+jwk+json", + "data": "v5bV9uO2QYiKrHfiaB3GhmxOD6ePeLKbF7Q-9zFx2HOGPgbbcDQiAxA8jg2XlMGn0l5PWKdlgW8e1Z3-m_ZRZaSE4MtqCY40zwggVWmAUH1_JAaw7vhw9ajT7LA6VsN2ni0t6VRQFCVaDXB7AcUyfm-Mb8HiShWEJocV826hFZhexZdJWv2iFwOLoKBlvMLHWaseSh1WKjY6N1jRbzEUdCfayi3idly6wE0CmauuzZYBlkuWDw_GxDTtrh8ZIjNBo6vnDVmaGyKCHudDPKXv82J8u-Kpspu85vTHTFSNBVnfT4MvKq_U4N3bpngQQtBR3k-jhzR9iwpeiCVMR1Y5Wy0C1_KFwqs-8tZLZL2T0433f-oR30MwTtQOLs3SEWd6-2-m5nYnVrMSiXHjRmcoxM9BmgHUEtKbO8mOsrz5Iga0xFlclBlGWEZyU6pe_QSqDkvpRsc1Rh_qgMydMrEt2FgqtreNdzQyc4P9d3NcudDxV3TKfwstFsqMu5IZsBsu5-HQi4Ax64tfpOEfP4MbgNNrEmBbuBeKKhijPTaK3MaUKSyojWJFNBnO8TS7pE1vn2-BjgohXHg9DKGgw4KTbyF7iDOfNaX9pLLhZtHOPrtLfS1n5JtisVkgovZAcQKqQUT0aTeVUIvx1Hv23ZOF4k7MxLpGEFV5cDghdJi-dUsIeNTKnOYuoOBZCOBpJCM63xItetP8_UQBLf249NnjG1Pue9iXmewAFS0lyN4SzgA8IWo-M1fK5sWMie3MXLuhpgRDoMxUkm2b4Im0a2AqnqWOaGcF3rd-xRgsHVbMOBFh5m-qpF3tYqlUdqhGxgI5evJrYnYV3ybF3x1mfF52wEbmBBCXCaFs5eydzLBI1GmZwgYxojI3gY81mtQIW_oA-mpefFKzHb0Ua8zWodq395itkbxXt1VdO-uBtMRU2cT5LoCZ0uEEFodc17x-eGFNG5qC43r87lunrppD2qY3Ng2bN4kCHV41Gc2kql-Z6P2dDazoF8eVtx3rYzVa05w3U9OezZ8v96o-Z0ZWy3Nu2Dqg2hbS6ufrLYcEAtM-B0Du1wSJRQEc3EqdCCTlfeChy4e5bQG8SaiH90r9DQH2fDZj-ItTZL6n8r_nhaAGFvEPpb713rwxwhvS0YI_Lqcm5X0gbLDS68JZ3KZuYEUU6Oo-TwXsVUIPRw-hLlen5wFSn-6R81v50rvt8rezyZImfsZUi7jKlqjBSNBUqAlCq4a1qk36NlTgfkRnmYqINvkOf8jCLmaYdyCw82nSrBhrHv_b--yXlF4LWAUSTixBXGY_dE8RioH-U_Y7v-helR2hKmqVH_LYNJ3dN39pjOA3US7iPvjfC7L2zBp_K7_QZCGBPfLwOjV9Rkh8G5GYUlmmvk2ET6Espkw5K3GhrfI7kK0D27XbenwJNh3xzl4TE8DBS9WsYScXSIRkEyb2SHtjgtQzadrI4JLih0snHNI_7aOtZihiD6l4BDo4mBH51O48UKJhlPOG6J-daaSMFsqIZfPSgeMopSCC1hHA_VG51CZGiAXKsMsPfG7wDorJ9ZVWznyKuLCQe8NsyJXcHL89nRLDiS5avNcdUQUcasDy3fbWMk0H3saMMdXl0QJLnAGwm2zW5k44sTr70UV_cRwiiJ4KVgffvmXgqhtyjbtJfozzBrGv54NTYws9icpm2YMAxJWz2IVhbYnbmE8osl55MZqs89LOu4EIfV7ujp0Vn2_5PCb__YEpqwt2wiKzBqt6dT0mSzrrVFqQaj5NKCJrhuA24LhHyrYyrTDHlqjTv5hv4yl41UwFAbFfkTwxQn_6kiKFUebVFXJ3tHtJFiYn_GRtIfdhNy-k8MMj3yPyLoUFhs4fDosbgJwbcTRONmn255ipEtKKr_zOA8z0jUElDeperKJp645Bccrs2ym7WUgPKeBkC6kZXiWIolNz_VB6JuGYR7mHTx0QjIr12pOpqTTTiEoKwTXFYy-Ei4_0idvAHqiHpVQMjxU3MabxG0S0KcP_qW7940ynBQxx9dUVw8btDQybOCvWUXK871RuAKcAY9gjhFUNZerPlDpEVoJr8uyAXidFpB-4NLdulFqVnbvwedwp3etDyPcUmQudallMG6UsUn1BrAGsHkXmML3KKJSGrd9OAa5gvJgOTpwHS5kfKkOufQtI-G2qW7KMnYJ33uBNYEvaanxzT-2rrCRG8LyfZi3etzcdplgQ_BAuaPmiqAIez1Yv2ft0QkfXNEvujbLtofWl37zcx8y3jrct2KRlMdiIC31sfK_P8jVCsUh6", + "enc": "A256GCM", + "iv": "9G-rUW-DRRTRyInc", + "kid": "yf2ji37vkqdow7pnbo3y37b3lu" + }, + "pubKey": + { + "alg": "RSA-OAEP", + "e": "AQAB", + "ext": true, + "key_ops": [ + "encrypt" + ], + "kid": "yf2ji37vkqdow7pnbo3y37b3lu", + "kty": "RSA", + "n": "uNcNDvgXCZasFUodoT737liqN_kFjZFd6ZhMxXulT8SRXF40d0PYI3HW64QXOibjUlqTrri1rGmM4XAKY8SgDPgIiYxSnJLpG42xFBFv5BwJgPnRg9k_BqSBYzokLJN7hUj-dqGHntOjFsQiNJ7OLv-2q06sPA2_ZvVuEeyC_MuycP0C6XXzpTwhxMTX78UEcDfucvXWTDW4n9N7PIgNQh7d5v7GGOhZbRzlbekrK8HdVUId8Mre53vSx3xn3MG8tMtVp4ppM8ouVM-7hVWkFinkfBQeCTlM-bdN1YBz7NVw9E6X_dsS7ByTRkUetVMqw--ctMVbNb2L-ewfaWn3xw" + }, + "encSPriKey": null, + "spubKey": null + }, + { + "uuid": "srkx3r5c3qgyzsdswfc4awgh2m", + "encryptedBy": "", + "sn": 1, + "encSymKey": + { + "cty": "b5+jwk+json", + "data": "h9cyj6GVagd-7OoLocM0nXODLVmzRVxZekamRslVfJxKDY067pSOnQcwmxvjL1ah9-HotZ3O3OoSi8zqu7oXhZ0NxauZB6G-bTklmEuVW0VirvCIxZf9K5sYBb-b5JhXulyxe4Ye761qfRDMKK0sy4WF-nYYqIPYCBUfXg1IDDZHfj3DVap2zpKYZDYBxy_R1ZzmyoQGwDxhUqysXBLKD7J_vqXY1S565mO8OZ5LnD9AepdD1CtQFq_06Eri9rc2kYOiuSIYvzxhty5O-fRBs-HS_t6Cgmzyz9iGDscxmQAn93gFb6ySqBhRR--T-4THVqhkteq-C2WxPeHmKhO3IQ", + "enc": "RSA-OAEP", + "kid": "szerdhg2ww2ahjo4ilz57x7cce" + }, + "encPriKey": + { + "cty": "b5+jwk+json", + "data": "laOkP5Cr98xOLeXw2WmwEEe1eB0WkVSeThhFXFiNGTjHs2leZ8weXjwozwByYmQginJ45UmTW14UoqcR5w7p1LMXkBvArjjiN_zLBg7x92HrvJRvZJY26KiSpgSB8mFCqe2EP_biMz-1tYySuhWLRnZEPqe6Vg7mP5sB0q4yP6p8zPUeg4EWXE4LgRXAUegUfrb4VoQueAz-1FE6DkZK92qBux6WHLYONZwtZb2u6Be4xoJ-iPCm3eIfkuzyEce0GJQpy3faoNbSgcfLXFf9zDJ_2UgcnaJRsxRkg-HC2DJkFMO5WbR3Fjr39dWJFMZD0w8GfGVTcp3J7dMm0fC6zH9mJpEwQIuf64ygczmh-VWufWwWl5OjReO4JhyIBZUDV8wEpOOKS4g2bRDWvyQR6XBwxtYxjNWiMFzLQq15hkI5iB713CORrKHeI3qWrkE5AJmhh6M12e0bApPtUvViUqibFjveY2ffGX8Eo8K0O0bQy769XNKEesXKQLOOw2BsKC590dNyaloDX8tCDdT0bpLqyZwZ9TrXF8vq1_bS0QcNWW7Pjhgpcr54c98ESDl5BFaeJ6g3_Pz7WgkFUnrBHyicCmw9sGiAHE4hZENCHINZliQPbq3T0t1iAcBbZ51fCp2oEt6MDwtppKoniSSfwQ7_cu-EBjY0t2K86vM5Om_MlMsPZpRI7MftMRoWI9LfWUkqrYbtFPGFz5wJfEqrVbrBPA9xpcaJjEygBRrvLHXRSTTkjG3PBnyVATyNnNAFjzofUx6Bew_4OoKwpYRs5CwQN0dwLYTD3S1IqsQZGJObe13l5Dv7umnhbN9Juj_lWYvOTNksWJlXJr_UYBaP6Z9DVGlUHKrea-SiSOZIG02pta0JitjEzs1CMjRVr5SeGkBcASPd2XEa-7p_RpiI0ZS5f-VZfKJ2IUBBCOHEnbs0Bok6wMx6K0TvXhua9sx-k_knMgJFr-HdCB_5FUdALFXDAgm5tDxSpzSCYIBAdO9Zc6T0VKDbRxDOw7sIbNkBf02M7sn2nOFKOooajoib8G-17dJ2XZcrN9rYSyInOdzf57pninyQuM9YqAjiIzceYSASkOdHbWsGu8L68_yGdKvoulWkVoaoABXHisxUMhlSR4i5zDR2I3DIMV36pii5uwZgwgLOCyxQf_b1CaDN9fSCDP8lm30h98ysSQyFgTTdFy5Qhumh2vs9i7qrMu6HE_ovw8YLPFsPMPriVX-51L9-tkYuWgQORpfEuKNABR3O_3xwIvma_pBwhp0JBcqu_6S_ju14TSPjHb_sbMDKoBZzUr45cwgOZwnmZ_qAJa3iDovBM-ZX_4H1KANzN66bzfhzsPdhUtYKO4iutC9XA5iOj-Dmiw-TFuvd2zIe6FLwrjmRrJSpHVTovmmnCNEeUXa9QCf7iKXVtWObBcDLzZRDMirkY0AqdH1-Sh6SQb8FILtQf6mP-E4_8dGb_oBvFA2IgYcIS3TlcSuF5SJqi8puOb3T1TLIVpbU28N6ZU8f1irGlAlnMe5CkOUZ1eOxI9Qx4NyyTs3gukUIUhtRCG7HGrgZUmg1TPNDpTjbZmLg6x_gh2tpVHqQBx1qfZNOGoRwJqoFo-9VzX6ttaEzxV1ofJtklE2umz1tiFmnleap8UAcouIodm_VrOsc9tz2nXwlbmARNajGRPEIRrRwdsXZuqxc2bJZelvIVGd60vA4oCalrerfRkVWjiEn2oqgixHXcA4E5H65jbsapqRXm2inhf_EJTBJ4SjBHuoSnF_tarrHmxtNQZUlfgtA726aSl1F3nftNHXIO5g6FBnKvCjNE1TjKDpMkvJeP6pVaoeiC7e_4auOdhDy55iQKyglbp4AM8nunqTRipGGZ9_jZrAK_HyVkUeFlN3V-vbgEYeRunN1ixt5K41GDUXXp8X1ZiOxx-R2u4uzLjqbK1u4fI5qbIF-9yjmsS2RYcn1MtAF76gytCwXTzDlAW2aPiwcgMz7Bjao604cKtkclZQ3GhrrVcJ3dYrDMsv3f654ugA8TwmBRjM9kNqAM1amBQTTEvHgn4-b07UbzNEfGUlQz_isC9x3LJ2JD-LYBZrmVVYGTPFPK_gmCfg8JvDQgKFc0KCXfae-pGxIx2Pmd_6-JrKnSKtUXmcg4O7g6zM_aKaMqUSreCc5eWVJ3VDpaxcBReK6zSDOGHi3wcCtFOlTTDnf-JyJwXYgu_18RTQaGvERSGp6fBNEXwtbyGw8oq615F-kr90M50vyNqEDSx5MgHZl-IGOJhGRPCIeAeeuhpnWcJFQ", + "enc": "A256GCM", + "iv": "yQF5t7T0jy5NhlFY", + "kid": "srkx3r5c3qgyzsdswfc4awgh2m" + }, + "pubKey": + { + "alg": "RSA-OAEP", + "e": "AQAB", + "ext": true, + "key_ops": [ + "encrypt" + ], + "kid": "srkx3r5c3qgyzsdswfc4awgh2m", + "kty": "RSA", + "n": "3maJITIY0fYVIzEY02T6SardLns46wpZ-SPPuNpxoo3YiE6HncT8nMRowj0wmkBeCHHT6PGuxYvQueoiAQQ2-G07Sb0E1EyjXgV6lC39Z-8XCbWq8lxuSPORoQ35IwDRsQxv6VXIziMHH_IL6snBTYLLZnmsTqibv0uba7IhMhB3vwn7vEMiFTSLAoshDNTelvF4y5yWXPBLbkzk4XP-bobkoowwEk9-wbkAG3hFRErWb2lavaoa47XplweYFbp4cbSy6AnN1lVENDNYPb2ReC3w9NEmzaE1ePk3GSfDWdCVw2vOEEqmg22lmjSbzUSNtCIppQ2YvDQjgS1DrCDRSQ" + }, + "encSPriKey": null, + "spubKey": null + }, + { + "uuid": "sm5hkw3mxwdcwcgljf4kyplwea", + "encryptedBy": "", + "sn": 1, + "encSymKey": + { + "cty": "b5+jwk+json", + "data": "OgV4YjIF4ig2SSNXVCyJBnWvsUhRF8emibmKbC_dhJG8nxkh0tvZ94bYvsIPqPVwXJIO-qJCgRMN7tBulMn0YAhSfeqslV3bCyNBIqC4O982v8trICyP3y7GlWqhDnBQg5t4Xs9M-SFZo6ADb4Jrl2CuJ0K36uMQgXS-Gmi-JRg040fEaFpoD1BnqI310d46dcIbYo1P71Qd73ADEuIZj29Zjzhrh6Yl3srGsmfShf9M79d4vreB0DkeRXfxo6jhWz0wB8DJL9RdVs0AmvIgBw6NU19LtviNhyk_3qD_y6bqgO3qAmvshRzFDox3SO_EMVJ7Qa_hp6ORLqhZrm2_bQ", + "enc": "RSA-OAEP", + "kid": "szerdhg2ww2ahjo4ilz57x7cce" + }, + "encPriKey": + { + "cty": "b5+jwk+json", + "data": "uneA6pT3fhhBHt31KnlFeK6GPKZwNyvDvxDKwbRgOLLoCQUetslQJc0NsiRKUgVe5s0WEyOeZqw6VHtoLaSoJahQJ8DNAh64_VnH9IYa-nLi0SlPHDmznBaxZmchj_IgwYY-nudFRhWfLKpapoOLarRB9Ch-G-Y7x0wfq4CcvcQaEpH6xtjwwSNqflIM3p4TMUJKYXRm9SyFrgKrij6qG6HW9UOr3Nmr0w3xZa2XCOeGkcynrcFnq8no1rPMauaaSAatQP5siuUhHV4oC5xINbRKmYxhWBDnj59hrZ246Np7JYnPQ8_nmybD9osIIK1t-YMff_DKkFHEyazZ2rd9ZHCXr5C5xAzhJ0r7M_meaLz3op5dzZxMMVprGhzidB6QcQ2Kfd1uUJOUXYUaFCuDFBSiS90vrnUxdQ2veTWMBujlycauIGeCiyNdiCvXn9lrNo2HQIalRL1TPKf72C3t9waDpNUVOElLSWv0FsW-TcEsK81TsskU8Q2mUk4FyDjgIUs7Ktdy-0gYjRKd5JtSTX-yx_EXm_qjvctNtIQ_YQjQ3sPl3DQ6FL98acs7FKJVOPyUz8A2oJ05H5szFtqXR8WivlqnokWIO1ZUKitDFnjnAvroYnL3LAwjShnbnLj1hJIkNnaZdvH7SOIYxrtgdjyhkdAbXuyY7QZaN_z2Ow2HyBO_36iSDrJF4ege9hK35bjkAvXovX7es7jFpa9FtuG2vaTb1un4lF9qn8lSTJ9WUlEg9WphzFs2hsu9bolNlPWjYEqwgrjGaWe02BeKY6MoaBVXFVVklr2GogM3vxrmtwm0PToVVxxiRy192xvqMr_ypGIdpywp1GrK_zL_512C65lyAMH25LsHdLq2I2uboCAT8c9cFu0aKEYFIZOdUtP-kMfDmPBM8z7Amrp9k3_uSGcCY5cjxzyeW41zCdIo4ASDVnWQ8d9GK8a7pea6Z2FDjLeBIArLjAx3g85GULWJ0H-uwv169bezRfRPO8aaOdWbHDtAARnonj7BpDeUU-tx1YcXiJKeluDWRHgU4pcTOwfv5Js6HQY6ZBlqna-2yKwjNXeeXs7fNqSgC272RBIJ8KSWx4_r-rUy-gGCfi-qmlxExoYmKgWBqOFMlkpIQ4MmTQkJIFbDH4JBcMmiwYAalwRveR8f7vyuWe9dRpbVutzBiTPJP90cCkrjfiSfXZtlnRDaLqZUqCOFciTQ-HT2yN6VFQxmXqEFytaTAP9LWt7b9unwSZpjno0J9Hie0y7UuYrhIo_eJH4sDtnv5xRW2cVv91vcSnXEUGDbqUKmV9kfcS99GwZGdCg08MnaUO5_KtjVDVohS4wwUaajBWW4i_ESO9G6HksyGBj2MyVGU4m19MqMwQFKgsJht1-_FX2o8OsKPITQqFumxObRO3OZA2MaYy1_NsX0RR417JooncYrXgGC9tTTiOTEX209A_yFWYyaB9tElYcPu_uK6ce_I28_5ASifuV-Yrj2ylRG_lOHaZf7qsMmn_Jl0DAytk4lRpL8ZvSlMj_ZpwvBNZ5e9iYgZFUYULxPqgAGTYqt2ed8sWdqe8R-s0UXeKGlWF62KJfLX6aJ1pdBwKP2nZmt-eMxdjBc2rEBYItzNKenSP-cIQxjjTJHGbSaeZjUV8r_IA_TeP7z4iS7uDd1jdRNAJvSmk37KATAk6H1JmvoyXHqgg3rorAu6swYxngvDEEixvRFfPOjtDdC8Hi8l6BiOQ1DsAmTBRuNr0tuYz4gSY9pCl65IKlAJs28GhC0I4GuPniMz5NaVZeazJU51bw9FCL8U7ANjknINC8DRfJY4ULsQ84LukCJI0bdvVIYIhaqbcLSHwRT2MMZtTxjAc8hDRBFL5BsJyQa7cmg5_WfSFJv181tZiFPzXtIPhUWuVcfGFedXpoRHvlxyeAtzdvKwdyNDf82WnLWF-4nHwFkHyV9v3rBptYITdqXl9A7d0SNV2EXvRLIj42quz0yXd5prXJ4OUKpsik_vPyeIHyJxvkbiQbj5p_bi9n9lXi5ZiyLJBim5JaGdXitoauVJOVkmSTRYbTThg2hKgMzYCHBOlOyLhzPrvjQMiIvHiFEBcRwKL6k-9ViiQ5extZVlRpL2-CZy714CCz2rg59nvWmGWdaRwghtZ7aCkZrUHIb_fTDiSCmafm2XgQmjMu6agUoYyo-zizif2seKzjpKFtyrkGzQ8XJR8R2FQMhd_-_s_aFmOxks3MEt9x9cE4JK2bVX92Z2oZ7n23s_W5ta7OR1WvlH0YJfVWk9t92g6YHp5fV", + "enc": "A256GCM", + "iv": "PbIggyhIDapK8-wM", + "kid": "sm5hkw3mxwdcwcgljf4kyplwea" + }, + "pubKey": + { + "alg": "RSA-OAEP", + "e": "AQAB", + "ext": true, + "key_ops": [ + "encrypt" + ], + "kid": "sm5hkw3mxwdcwcgljf4kyplwea", + "kty": "RSA", + "n": "1tej9lIjZsFe3N_CbQSVpdxxl4BUVd-fKxfjMt5aN6OgtTb5J_TtJG1hqwtBVscudPydut_4IZQKeCPEWofabvNsYcsupoehLWf3X9i2AlmMk6UPeOCaHC4UFs-eto1MU8G5pKqoawEfxGwD7_QEFqeQ_AkXSX-ina8SqzSmCdpCPrSq_E-NoPFxA-5SQWm2O7DbBKuFFyyIlbFkPDVB4tZy3Mer3gtcoDRz4rMo2lKdSrOP6GhfiBX1yVNi8cYX5T9SHNFhNNxU29RhAat3xuoU7uqQs2HNzK9nHCPDpzNTi5GCZZXkSZ-MyYKn7bMTMP0SmXnW8WPL8UB3xjnwKQ" + }, + "encSPriKey": null, + "spubKey": null + }] +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/fixtures/rsa-key-oaep-256.json b/crates/bitwarden-importers/src/importers/onepassword/access/fixtures/rsa-key-oaep-256.json new file mode 100644 index 000000000..514bbb74e --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/fixtures/rsa-key-oaep-256.json @@ -0,0 +1,15 @@ +{ + "alg": "RSA-OAEP-256", + "d": "Ho-bxfKvqIptoea5Z7bgp-LMWt5-SxFv7RA7AgoarxhPDGCzOwDBUnzXwYxoVh_ljgLn9asUc510aYfDtUuXU5u0pO2UPhpSFHSkC1KDWXOIckH_q6d68kwmnhb_SeXEv0DIMbbm4ZmqUK4Ko855_CZMR7Kzm-xekwetM0FQymzkwpBTP9UjziO0ApnbudhaHcwil5NbghydiRI6z73q63ZS07jloExUFHLpAUW2ON_Fo0Q9t6KfoTAV1_o8BCvRVSQ4uN6hgggUXdllGnIJkMFI6djdp8gU20jZ6sVYJ5QwjDGu988DOftcXNg6gjOBzgHO1m6O2c8nCJE0uYPMSQ", + "dp": "KmrO99TwvM4Fv46UDdfNJNcr3S7lIQUS18racKp8R91TadcRwPP9q4ECbh4awO3V7elYnSAJytUmb6lMw963SSHULES-WhgA2qhfOQwubudL5hDuZ-t6FOdHJmL0hIEG5sp3C3MGvlnmAJ43XZnBAhi8cXFnTnilslekXwb1z8s", + "dq": "kK9xSdUZQLcIwVcjCCDRGR6m3jeJWWidDNCtWOAr4RzwRbgwDuEduSzmmhiZOkJqrnzvvC_9pU7H1YlVOLRzgsv_N2hSyxSZrVdptQCmYRstH4s1FU5H6wkUiwVW5dEKRR0j_hachyGg3YTKOBxgYtj52YEzEsHRQr4bysSn6IU", + "e": "AQAB", + "ext": true, + "key_ops": ["decrypt"], + "kty": "RSA", + "n": "vdiNdN-SZg_4iMS6IFQQeHCxwqENrM_I1SauR9pCtd53_ByGz7Yk3XM30eZTDJM8FDMzBRKKq1wpISacruNVWKRtsetaskRu0I3qjGnD0ijoszwtLHttrJon8lp-r-a7KNn8qdX8nXJ7bBY16f59kHuquzUHAOvrmjyrSa6p2f_gYOWxsfkrJiHkuO2b1D1wAWEX8t1EhaqSGXn1I8R2qJlhjqTOgh8Uk8esz-b1fjRZNuo38eCKhCO6l7zsjP7Ah-1ngd04VuYD1RqRWIzk6dvxDmrauu4Fy82_tTEP1nKzNUxylf4tGct_Xj-RhKuRicZuY-d9e2mFw8KrX7NlZQ", + "p": "_tN4DEtjkFzNCyeIVDIZ-CtiH46pkc3rIoM7bibWHq9332RzvTUSvPibPHyVIfhquKrN3MSgfKGTXhKMgAXk0mITPr-ZGA_WwsG9VypYctcpu4sNX2PZ_huNJTKLnXm1awR6crUrMBlCNk61HOn6mV-X27d1Qs8fWfraogvk19M", + "q": "vrhy5Fdigq8jAU6rQNijAgxLvI9Y2K6GISsw5JrIt1nrcCilN7VXjkgQmGhQ6pvq9JPUgbaViOpJh6miRwDq6nwaLLY_BWyJDo6KiCxhrRE4NewmBe2s-9BWm314rcpD2tdrw_R6yF7drA7-Hr4qu6idM-MbR4ZWq-m1GKVHAuc", + "qi": "ZgSqXIqyBQly-BbmAH-VGbTqojEm9LTrQy2Olyei0FF2y0Crfz6kHP79vhjS5lc2-HV_Yv5BEqQN7gg_ux2HtT0N-7MshHfZhRGu_nmU-k28JnRQlrwt4L_3Jh3lLghDj93IlthKcppt7HRstmMHBgThS9ws7Lxg45Zp2EVo80M", + "kid": "sfaijsnbchbtznlar7mx6yrhae" +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/fixtures/rsa-key.json b/crates/bitwarden-importers/src/importers/onepassword/access/fixtures/rsa-key.json new file mode 100644 index 000000000..c8cc816af --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/fixtures/rsa-key.json @@ -0,0 +1,14 @@ +{ + "alg": "RSA-OAEP", + "d": "BF-8y2XKmagkXNv7OP88oypfrtKGgLq6TNS3X2hMZoBZfGXX3YckJrPZelFXMX5OgOyU2yvzT_U-XadpyypX1j4OapsqAEir985PBJ3Y7tgeMXc_dTan00qQAKB7c2gaLjokvJGaDOx-h86RHsoLcUaC7wMnsc0SMcUiihXfMNAA0PkdkmomT79H8m0HYBGYQCGKj323K7XScdTyEBGe5IGea5Y-Gy2qeX42Js4uxFwz0CymkTz9hTJOOxCNwGbgRbk2I2yOiLQysY5whp_B6MBAEzocJAM8V4PFmPCuw8jlyL79v5i6oIMW7lKo5hbyRhINECJ2xAWEeY78hl2asQ", + "dp": "SODQhw9KS6eOBC02dYkxNPf2E9yQ4el__NOsbg6bgSul9Er9_z9bp60WgBf8u9b7wMclZ-9iO17-hinxNQy4_MQrs6KCssaejJ510qV3zBkW6kXcdbvLyB1C0xQe4LPmdgDAtt8Ft31_rLN_VPP_GgreWpU6rrUKXzx47hpE60E", + "dq": "Sh1S6l90OUXWD1Oz7lb_VtUXygqxknVFFDfmSOdjw5X4IYjromB-FxdacffTBo-I7v-MfrJqqWsDtgpJvVAePExHQ0J-ud0gHtl-ClKOMnDVZeu5apU4WWNN-dhcyE43eHXt1SsNM-wUwoxsjTFy5ibCbuiH6dSUyk81TtxDnKU", + "e": "AQAB", + "ext": true, + "kty": "RSA", + "n": "v1wkFPMC-1eAV46KP_46g8L2-eImJyybx8dMPXdAZJ04hd3dtq_3bbbjUXxT4X7NoCSIrGZHE5O1TTTiBX3zHLEoyT33s-ViLNJXUHfkF2vXQYcqHnBIyr9H-p08eki7A9Cn8K1kk2d4BPj7GtvPWYp11fmgdmPNN5jVJKRG3ggrnluA4DB_txNnTPOoql-l5JCacFwiGIdYH3oaHZ9jjGTVwaFIDTv39ttnrA5EXyEJOPxQK9S-3qk69LJShfSrmWuTU_QUZjCOiVOoRtzcHzCRcQjWzGvTLFe64qze03exMBUU8O4ACfGUjlu-UIiK0V6F6gamofZmmL5pHr7Gjw", + "p": "91GR0HzxTGEOXa7ofmeXn9R0YqjkMfrvVAnhDpGf6uKc80lqZVjM-mOpNx_8BVoHhrgub9aQXi_aULVwnBNGVGBnZO8dw3bKmD7QFLkWFCkvxCVrDNXQHjzN8yd5zp5Rk4CeiW9OI0Al42i5D1CmeC22Q0gutpxvaDrb3yriKu0", + "q": "xhO4gaSTf5KWAiXoral-fAbq4dBOz2boptXJmN_KVGlv4vJ2K0NXH-XKsDPVI5WCwE104mcdoDlDOJ_ZU9lzx5GgvoUwNr5luScdtY0LY7rYq-OuTYf5o2BPJOsAh2mAFISbX6gqP7HrO1SH0LVmSHfaTz8lti0gX0A9yETX--s", + "qi": "FW4wJf5lP5k7OfH4-w34BvHzPnnhj96_GfKUcyzeNRL1XVJ64a7C3OIno30DQTrUHtbVeZ2zsnPLwtvuMkQEVXcvt6pLCxGnWx9eO71v4yzlJ4_6wDx5xvKwNFrspOBDAC_XeG1GyJu-cVd5azNARKaf0JPFO7MQ3NJ7-ymFQTo", + "kid": "szerdhg2ww2ahjo4ilz57x7cce" +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/fixtures/vault-item-with-lots-of-fields.json b/crates/bitwarden-importers/src/importers/onepassword/access/fixtures/vault-item-with-lots-of-fields.json new file mode 100644 index 000000000..8437391d2 --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/fixtures/vault-item-with-lots-of-fields.json @@ -0,0 +1,110 @@ +{ + "sections": [ + { + "name": "Section_l2bagl3iupehvr7jvrc62mjhee", + "title": "Section1", + "fields": [ + { + "t": "otp1", + "v": "blahblahblah", + "k": "concealed", + "n": "TOTP_q5u5xxcc4rmokuco2qkkbarrom" + }, + { + "t": "otp2", + "v": "pfpfpfpfpf", + "k": "concealed", + "n": "TOTP_ahr55l6iut7azxlplq4tdm5q6u" + }, + { + "t": "email1", + "v": "blah@email.com", + "k": "email", + "n": "pzekjqeac6jdqlqpwbknvj3asm" + }, + { + "t": "sign in with1", + "v": { + "provider": "github" + }, + "k": "ssoLogin", + "n": "dlonqn76xyeqyp6dgh6ofrwsky" + }, + { + "t": "phone1", + "v": "123456567", + "k": "phone", + "n": "cgcfjq6sv2fueff2phpujsstxe" + }, + { + "t": "date1", + "v": 1694692860, + "k": "date", + "n": "7yrnhnetvserrkytvv6qxxiaqy" + } + ] + }, + { + "name": "Section_4hckr3r222l3l7kzoyo7aqcsoq", + "title": "Section2", + "fields": [ + { + "t": "otp1", + "v": "pfpfpfpfpf", + "k": "concealed", + "n": "TOTP_d5nkqmsaeog4aioa5qnsfmifpe" + }, + { + "t": "otp2", + "v": "1234567890", + "k": "concealed", + "n": "TOTP_tue2sn3mp7567vyvtuzk72f4am" + }, + { + "t": "url2", + "v": "https://blah.com", + "k": "URL", + "n": "lwflmcxuoxkeicly3tm5fsoeoi" + }, + { + "t": "date2", + "k": "date", + "n": "myx5fzoq3dq5wle4wzvvavsza4" + }, + { + "t": "", + "v": { + "street": "main st", + "city": "ville", + "country": "vi", + "zip": "12345", + "state": "ca" + }, + "k": "address", + "n": "nhzzd6nu73fn7sl4gjmwa5fxxu" + }, + { + "t": "mo/ye", + "v": 202112, + "k": "monthYear", + "n": "kwkr32jrtllqhexeedk7tofpca" + } + ] + } + ], + "fields": [ + { + "name": "username", + "value": "", + "type": "T", + "designation": "username" + }, + { + "name": "password", + "value": "", + "type": "P", + "designation": "password" + } + ], + "notesPlain": "" +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/identity.rs b/crates/bitwarden-importers/src/importers/onepassword/access/identity.rs new file mode 100644 index 000000000..9c8acacfd --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/identity.rs @@ -0,0 +1,59 @@ +//! The 1Password client this module impersonates. +//! +//! Every value the server sees as our client identity is defined here and nowhere else, so +//! refreshing a fingerprint that has gone stale is a single-file change. None of it is load-bearing +//! for the protocol: it only has to look like a plausible 1Password desktop client. +//! +//! Taken from 1Password for Mac/Windows/Linux 8.12.10, released 7 April 2026: +//! - +//! - +//! - +//! +//! Whether per-platform impersonation is needed at all, or whether one fixed identity would do, is +//! an open question. See the development notes in this module's README. + +/// The desktop app's build number, sent as the client version and inside the op user agent. +pub(super) const VERSION: &str = "81210036"; + +/// The HTTP library the desktop app reports. That app is itself written in Rust, so this is *its* +/// reqwest version, not ours. Do not sync it with the workspace's reqwest pin; it is part of the +/// fingerprint, and the two are unrelated. +pub(super) const HTTP_LIB: &str = "reqwest|0.12.24"; + +/// The per-platform half of the identity. The version and HTTP library above are the same +/// everywhere, so only these four fields vary. +pub(super) struct Platform { + /// Names the client: `1Password for Mac`. + pub os: &'static str, + /// Single-letter platform code, the second field of the op user agent. + pub op_code: &'static str, + /// `os|version|arch`, the last field of the op user agent. + pub os_suffix: &'static str, + /// The `osName` in the device descriptor. + pub os_name: &'static str, +} + +#[cfg(target_os = "macos")] +pub(super) const PLATFORM: Platform = Platform { + os: "Mac", + op_code: "M", + os_suffix: "MacOSX|26.3.1|aarch64", + os_name: "macOS", +}; + +#[cfg(target_os = "linux")] +pub(super) const PLATFORM: Platform = Platform { + os: "Linux", + op_code: "L", + os_suffix: "Linux|Ubuntu 24.04|x86_64", + os_name: "Linux", +}; + +// Every other target, wasm32 included, presents as the Windows build. +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +pub(super) const PLATFORM: Platform = Platform { + os: "Windows", + op_code: "W", + os_suffix: "Windows|25H2 11.0.26200|x86_64", + os_name: "Windows", +}; diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/kdf.rs b/crates/bitwarden-importers/src/importers/onepassword/access/kdf.rs new file mode 100644 index 000000000..be11c13ff --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/kdf.rs @@ -0,0 +1,128 @@ +//! HKDF-SHA256, PBES2 (PBKDF2 HS256/HS512), and master-key derivation. + +use hkdf::Hkdf; +use hmac::Hmac; +use icu_normalizer::ComposingNormalizer; +use sha2::{Sha256, Sha512}; + +use super::{account_key::AccountKey, error::OnePasswordError}; + +/// HKDF-SHA256 producing 32 bytes, with `method` as the `info` parameter. +pub(super) fn hkdf_sha256(method: &str, ikm: &[u8], salt: &[u8]) -> [u8; 32] { + let hk = Hkdf::::new(Some(salt), ikm); + let mut okm = [0u8; 32]; + hk.expand(method.as_bytes(), &mut okm) + .expect("okm is a fixed 32 bytes, under HKDF's 255-block limit"); + okm +} + +/// PBES2 key derivation dispatching on the JWK method name. +/// +/// `PBES2[g]-HS256` uses PBKDF2-HMAC-SHA256, `PBES2[g]-HS512` uses PBKDF2-HMAC-SHA512, both 32 +/// bytes. +pub(super) fn pbes2( + method: &str, + password: &str, + salt: &[u8], + iterations: u32, +) -> Result<[u8; 32], OnePasswordError> { + let password = password.as_bytes(); + let derived = match method { + "PBES2-HS256" | "PBES2g-HS256" => { + pbkdf2::pbkdf2_array::, 32>(password, salt, iterations) + } + "PBES2-HS512" | "PBES2g-HS512" => { + pbkdf2::pbkdf2_array::, 32>(password, salt, iterations) + } + _ => { + return Err(OnePasswordError::Unsupported(format!( + "Method '{method}' is not supported" + ))); + } + }; + + Ok(derived.expect("HMAC accepts any password length")) +} + +/// Derives the 32-byte master unlock key (kid `"mp"`). +/// +/// `k1 = HKDF(info = algorithm, ikm = salt, salt = lower(username))`; `k2 = PBES2(algorithm, +/// NFC(password), k1, iterations)`; result `= account_key.combine_with(k2)`. +pub(super) fn derive_master_key( + algorithm: &str, + iterations: u32, + salt: &[u8], + username: &str, + password: &str, + account_key: &AccountKey, +) -> Result<[u8; 32], OnePasswordError> { + let k1 = hkdf_sha256(algorithm, salt, username.to_lowercase().as_bytes()); + let normalized = ComposingNormalizer::new_nfc().normalize(password); + let k2 = pbes2(algorithm, &normalized, &k1, iterations)?; + account_key.combine_with(&k2) +} + +#[cfg(test)] +mod tests { + use data_encoding::{BASE64URL_NOPAD, HEXLOWER}; + + use super::*; + + #[test] + fn hkdf_returns_derived_key() { + let derived = hkdf_sha256("PBES2g-HS256", b"ikm", b"salt"); + assert_eq!( + BASE64URL_NOPAD.encode(&derived), + "UybCHXHHQRaFxUUR3G2ZO9CJ0H2eWJ1Ik_MpNQHrHdE" + ); + } + + #[test] + fn pbes2_returns_derived_key() { + let cases = [ + ( + "PBES2g-HS256", + "B-aZcYDPfxKQTwQQDUBdNIiP32KvbVBqDswjsZb-mdg", + ), + ( + "PBES2g-HS512", + "_vcnaxBwQKCnE7y-yf0-GRzGFTJJ4kWj4aIgh9vmFgY", + ), + ]; + for (method, expected) in cases { + let key = pbes2(method, "password", b"salt", 100).expect("supported method"); + assert_eq!(BASE64URL_NOPAD.encode(&key), expected); + } + } + + #[test] + fn pbes2_throws_on_unsupported_method() { + let err = pbes2("Unknown", "password", b"salt", 100).expect_err("unsupported"); + assert!(matches!(err, OnePasswordError::Unsupported(_))); + assert!(err.to_string().contains("is not supported")); + } + + #[test] + fn derive_master_key_returns_master_key() { + let salt = BASE64URL_NOPAD + .decode(b"i2enf0xq-XPKCFFf5UZqNQ") + .expect("valid salt"); + let account_key = + AccountKey::parse("A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9").expect("valid account key"); + + let key = derive_master_key( + "PBES2g-HS256", + 100000, + &salt, + "username", + "password", + &account_key, + ) + .expect("derivation succeeds"); + + assert_eq!( + HEXLOWER.encode(&key), + "09f6cf6acc4f64f2ac6af5d912427253c4dd5e1a48dfc6bfea21df8f6d3a701e" + ); + } +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/keychain.rs b/crates/bitwarden-importers/src/importers/onepassword/access/keychain.rs new file mode 100644 index 000000000..cc8e869aa --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/keychain.rs @@ -0,0 +1,354 @@ +//! Key store keyed by kid, scheme dispatch, and keyset topological decrypt. + +use std::collections::{HashMap, VecDeque}; + +use serde::de::DeserializeOwned; + +use super::{ + account_key::AccountKey, + error::OnePasswordError, + kdf, + opdata::{AesKey, Encrypted, decode64_loose}, + rsa::RsaKey, + wire::{AesKeyJson, EncryptedEnvelope, KeysetInfo, RsaKeyJwk}, +}; + +const AES_SCHEME: &str = "A256GCM"; +const RSA_SCHEMES: [&str; 2] = ["RSA-OAEP", "RSA-OAEP-256"]; +const MASTER_KEY_ID: &str = "mp"; + +/// A store of AES and RSA keys keyed by their kid. +#[derive(Default)] +pub(super) struct Keychain { + aes: HashMap, + rsa: HashMap, +} + +impl Keychain { + pub(super) fn new() -> Keychain { + Keychain::default() + } + + pub(super) fn add_aes(&mut self, key: AesKey) { + self.aes.insert(key.id.clone(), key); + } + + pub(super) fn add_rsa(&mut self, key: RsaKey) { + self.rsa.insert(key.id.clone(), key); + } + + #[cfg(test)] + fn get_aes(&self, id: &str) -> Option<&AesKey> { + self.aes.get(id) + } + + #[cfg(test)] + fn get_rsa(&self, id: &str) -> Option<&RsaKey> { + self.rsa.get(id) + } + + /// Decrypts an envelope by dispatching on its scheme to the AES or RSA key named by its kid. + pub(super) fn decrypt(&self, encrypted: &Encrypted) -> Result, OnePasswordError> { + if encrypted.scheme == AES_SCHEME { + let key = self.aes.get(&encrypted.key_id).ok_or_else(|| { + OnePasswordError::Internal(format!("AES key '{}' not found", encrypted.key_id)) + })?; + return key.decrypt(encrypted); + } + + if RSA_SCHEMES.contains(&encrypted.scheme.as_str()) { + let key = self.rsa.get(&encrypted.key_id).ok_or_else(|| { + OnePasswordError::Internal(format!("RSA key '{}' not found", encrypted.key_id)) + })?; + return key.decrypt(encrypted); + } + + Err(OnePasswordError::Unsupported(format!( + "Encryption scheme '{}' is not supported", + encrypted.scheme + ))) + } + + /// Whether the keychain currently holds the key needed to decrypt this envelope. + /// + /// A scheme this module does not implement is an error, not a `false`: the caller cannot tell + /// "we lack the key" from "we cannot read this format" otherwise, and would silently drop data. + pub(super) fn can_decrypt(&self, encrypted: &Encrypted) -> Result { + if encrypted.scheme == AES_SCHEME { + return Ok(self.aes.contains_key(&encrypted.key_id)); + } + + if RSA_SCHEMES.contains(&encrypted.scheme.as_str()) { + return Ok(self.rsa.contains_key(&encrypted.key_id)); + } + + Err(OnePasswordError::Unsupported(format!( + "Encryption scheme '{}' is not supported", + encrypted.scheme + ))) + } + + /// Decrypts an envelope and parses its JSON plaintext. + pub(super) fn decrypt_json( + &self, + envelope: &EncryptedEnvelope, + ) -> Result { + let plaintext = self.decrypt(&Encrypted::parse(envelope)?)?; + serde_json::from_slice(&plaintext).map_err(|_| OnePasswordError::Parse) + } + + /// Derives the master key from the credentials, then decrypts every keyset into the keychain. + pub(super) fn decrypt_keysets( + &mut self, + keysets: &[KeysetInfo], + username: &str, + password: &str, + account_key: &AccountKey, + ) -> Result<(), OnePasswordError> { + let master_key = derive_master_key(keysets, username, password, account_key)?; + self.decrypt_reachable(keysets, master_key) + } + + /// Seeds `root_key` and decrypts everything reachable from it. + fn decrypt_reachable( + &mut self, + keysets: &[KeysetInfo], + root_key: AesKey, + ) -> Result<(), OnePasswordError> { + let order = decryption_order(keysets, &root_key.id); + self.add_aes(root_key); + + for index in order { + self.decrypt_keyset(&keysets[index])?; + } + + Ok(()) + } + + /// Decrypts a keyset's symmetric key then its private key into the keychain. + fn decrypt_keyset(&mut self, keyset: &KeysetInfo) -> Result<(), OnePasswordError> { + self.decrypt_aes_key(&keyset.enc_sym_key.envelope())?; + self.decrypt_rsa_key(&keyset.enc_pri_key) + } + + /// Decrypts an encrypted AES key and adds it to the keychain. + pub(super) fn decrypt_aes_key( + &mut self, + envelope: &EncryptedEnvelope, + ) -> Result<(), OnePasswordError> { + let plaintext = self.decrypt(&Encrypted::parse(envelope)?)?; + let json: AesKeyJson = + serde_json::from_slice(&plaintext).map_err(|_| OnePasswordError::Parse)?; + self.add_aes(AesKey::new(json.kid, decode64_loose(&json.k)?)); + Ok(()) + } + + /// Decrypts an encrypted RSA key and adds it to the keychain. + fn decrypt_rsa_key(&mut self, envelope: &EncryptedEnvelope) -> Result<(), OnePasswordError> { + let plaintext = self.decrypt(&Encrypted::parse(envelope)?)?; + let jwk: RsaKeyJwk = + serde_json::from_slice(&plaintext).map_err(|_| OnePasswordError::Parse)?; + self.add_rsa(RsaKey::parse(&jwk)?); + Ok(()) + } +} + +/// Derives the key of the newest master keyset, the only one carrying KDF parameters. +fn derive_master_key( + keysets: &[KeysetInfo], + username: &str, + password: &str, + account_key: &AccountKey, +) -> Result { + let master = keysets + .iter() + .filter(|k| k.encrypted_by == MASTER_KEY_ID) + .max_by_key(|k| k.sn) + .ok_or_else(|| OnePasswordError::Internal("Master keyset not found".into()))?; + + let info = &master.enc_sym_key; + let algorithm = info.alg.as_deref().ok_or_else(|| { + OnePasswordError::Internal("master keyset is missing the algorithm".into()) + })?; + let salt = + decode64_loose(info.p2s.as_deref().ok_or_else(|| { + OnePasswordError::Internal("master keyset is missing the salt".into()) + })?)?; + // `pbkdf2` runs `1..rounds`, so a missing count would stretch the password exactly once. + if info.p2c == 0 { + return Err(OnePasswordError::Internal( + "master keyset is missing the iteration count".into(), + )); + } + let key = kdf::derive_master_key(algorithm, info.p2c, &salt, username, password, account_key)?; + + Ok(AesKey::new(MASTER_KEY_ID, key.to_vec())) +} + +/// Orders keysets so each one comes after the key that encrypts it, starting from `root_id`. +/// Keysets the root cannot reach are left out. +fn decryption_order(keysets: &[KeysetInfo], root_id: &str) -> Vec { + let mut encrypts: HashMap<&str, Vec> = HashMap::new(); + for (index, keyset) in keysets.iter().enumerate() { + encrypts + .entry(encrypted_by(keyset)) + .or_default() + .push(index); + } + + // Visit each keyset once, tracked by position: an entry whose encrypter resolves to its own + // uuid would re-enqueue forever, and uuids repeat in real responses. + let mut visited = vec![false; keysets.len()]; + let mut queue: VecDeque = encrypts.get(root_id).cloned().unwrap_or_default().into(); + let mut order = Vec::with_capacity(keysets.len()); + while let Some(index) = queue.pop_front() { + if std::mem::replace(&mut visited[index], true) { + continue; + } + + order.push(index); + if let Some(children) = encrypts.get(keysets[index].uuid.as_str()) { + queue.extend(children); + } + } + + order +} + +/// The key id that encrypts a keyset: its explicit `encryptedBy`, or the symmetric key's kid when +/// that is empty. +fn encrypted_by(keyset: &KeysetInfo) -> &str { + if keyset.encrypted_by.is_empty() { + &keyset.enc_sym_key.kid + } else { + &keyset.encrypted_by + } +} + +#[cfg(test)] +mod tests { + use data_encoding::HEXLOWER; + + use super::{super::wire::KeysetsInfo, *}; + + fn hex(s: &str) -> Vec { + HEXLOWER.decode(s.as_bytes()).expect("valid hex") + } + + #[test] + fn decrypt_aes_key_adds_key_to_keychain() { + let mut keychain = Keychain::new(); + keychain.add_aes(AesKey::new( + "mp", + hex("44c38e8fedb84a1ab5ba74ed98dde931f6500ae39c1d9c85e20a7268ab2074f0"), + )); + + let envelope: EncryptedEnvelope = + serde_json::from_str(include_str!("fixtures/encrypted-aes-key.json")) + .expect("valid fixture"); + keychain.decrypt_aes_key(&envelope).expect("decrypts"); + + assert!(keychain.get_aes("szerdhg2ww2ahjo4ilz57x7cce").is_some()); + } + + #[test] + fn decrypt_rsa_key_adds_key_to_keychain() { + let mut keychain = Keychain::new(); + keychain.add_aes(AesKey::new( + "szerdhg2ww2ahjo4ilz57x7cce", + hex("bba932f6032dc4dffaa9b8f03c9fd4b810127b89a49408db7b914a131690c091"), + )); + + let envelope: EncryptedEnvelope = + serde_json::from_str(include_str!("fixtures/encrypted-rsa-key.json")) + .expect("valid fixture"); + keychain.decrypt_rsa_key(&envelope).expect("decrypts"); + + assert!(keychain.get_rsa("szerdhg2ww2ahjo4ilz57x7cce").is_some()); + } + + #[test] + fn decrypt_keysets_decrypts_all_keys() { + let keysets: KeysetsInfo = + serde_json::from_str(include_str!("fixtures/get-keysets-response.json")) + .expect("valid fixture"); + let mut keychain = Keychain::new(); + + // This fixture's master keyset carries a `p2s` that no longer matches its encrypted data, + // so the walk starts from the master key directly (the derivation is pinned by the kdf + // vector). + let master_key = AesKey::new( + MASTER_KEY_ID, + hex("44c38e8fedb84a1ab5ba74ed98dde931f6500ae39c1d9c85e20a7268ab2074f0"), + ); + keychain + .decrypt_reachable(&keysets.keysets, master_key) + .expect("decrypts keysets"); + + assert!(keychain.get_aes("mp").is_some()); + for id in [ + "szerdhg2ww2ahjo4ilz57x7cce", + "yf2ji37vkqdow7pnbo3y37b3lu", + "srkx3r5c3qgyzsdswfc4awgh2m", + "sm5hkw3mxwdcwcgljf4kyplwea", + ] { + assert!(keychain.get_aes(id).is_some(), "missing AES key {id}"); + assert!(keychain.get_rsa(id).is_some(), "missing RSA key {id}"); + } + } + + fn keyset(uuid: &str, encrypted_by: &str) -> KeysetInfo { + serde_json::from_value(serde_json::json!({ + "uuid": uuid, + "encryptedBy": encrypted_by, + "sn": 1, + "encSymKey": {"kid": encrypted_by, "enc": AES_SCHEME, "cty": "b5+jwk+json", "data": ""}, + "encPriKey": {"kid": encrypted_by, "enc": AES_SCHEME, "cty": "b5+jwk+json", "data": ""}, + })) + .expect("valid keyset") + } + + #[test] + fn decryption_order_follows_the_chain_from_the_root() { + let keysets = [ + keyset("c", "b"), + keyset("a", MASTER_KEY_ID), + keyset("b", "a"), + keyset("orphan", "nobody"), + ]; + + // Every keyset comes after the one that encrypts it, and what the root cannot reach is + // left out. + assert_eq!(decryption_order(&keysets, MASTER_KEY_ID), vec![1, 2, 0]); + } + + #[test] + fn decryption_order_visits_a_self_referential_keyset_once() { + // A keyset naming itself as its encrypter would re-enqueue forever, so a regression here + // hangs rather than fails. + let keysets = [keyset("a", MASTER_KEY_ID), keyset("a", "a")]; + + assert_eq!(decryption_order(&keysets, MASTER_KEY_ID), vec![0, 1]); + } + + #[test] + fn decrypt_rejects_unknown_scheme() { + let keychain = Keychain::new(); + let encrypted = Encrypted { + key_id: "mp".into(), + scheme: "A128CBC".into(), + iv: Vec::new(), + ciphertext: Vec::new(), + }; + + let err = keychain + .can_decrypt(&encrypted) + .expect_err("unsupported scheme"); + assert!(matches!(err, OnePasswordError::Unsupported(_))); + + let err = keychain + .decrypt(&encrypted) + .expect_err("unsupported scheme"); + assert!(matches!(err, OnePasswordError::Unsupported(_))); + } +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/login.rs b/crates/bitwarden-importers/src/importers/onepassword/access/login.rs new file mode 100644 index 000000000..f8d6186fd --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/login.rs @@ -0,0 +1,420 @@ +//! The password + Secret Key login state machine. +//! +//! One attempt runs: start a session (registering the device if the server asks), exchange SRP, +//! confirm the key, then complete authentication over the MAC-signed encrypted channel, driving 2FA +//! when the account requires it. + +use serde_json::json; + +use super::{ + account_key::AccountKey, + credentials::Credentials, + device::{ClientInfo, reauthorize_device, register_device}, + error::OnePasswordError, + mac::MacSigner, + opdata::{AesKey, decode64_loose}, + rest::RestClient, + session::Session, + srp::{self, SrpInfo}, + two_factor::{MfaOutcome, TwoFactorUi, perform_second_factor_authentication}, + wire::{AuthComplete, LoginInfo, MfaInfo, NewSession}, +}; + +/// How many times the server may send us back to register or reauthorize the device before we give +/// up. One round is the normal case. +const MAX_DEVICE_ATTEMPTS: u32 = 2; +const AUTH_METHODS_ENDPOINT: &str = "v2/auth/methods"; +const AUTH_START_ENDPOINT: &str = "v3/auth/start"; +const AUTH_COMPLETE_ENDPOINT: &str = "v2/auth/complete"; + +/// The result of a single login attempt: a finished session, or a rejected OTP that asks for a full +/// restart. +pub(super) enum LoginOutcome { + /// Authentication succeeded. + Success(Box), + /// The submitted TOTP code was rejected; the caller should retry from the start. + BadOtp, +} + +/// Confirms the account offers a given auth method. +pub(super) async fn fetch_auth_methods( + username: &str, + rest: &RestClient, +) -> Result { + rest.post_json(AUTH_METHODS_ENDPOINT, json!({ "email": username })) + .await +} + +/// Runs one full login sequence: start a session, exchange SRP, verify the key, and drive 2FA if +/// the server asks for it. +pub(super) async fn login_attempt( + credentials: &Credentials, + account_key: &AccountKey, + client_info: &ClientInfo, + attempt: u32, + ui: &dyn TwoFactorUi, + rest: &RestClient, +) -> Result { + // Step 1: Request to initiate a new session + let (session_id, srp_info) = + start_new_session(credentials, account_key, client_info, rest).await?; + + // After a new session has been initiated, all the subsequent requests must be signed with the + // session ID. + let session_rest = rest.with_session_id(&session_id)?; + + // Step 2: Perform SRP exchange and verify key + let session_key = srp::perform_and_verify( + credentials, + account_key, + &srp_info, + &session_id, + &session_rest, + ) + .await?; + + // Assign a request signer now that we have a key. All the following requests are expected to be + // signed with the MAC. + let mac_rest = session_rest.with_signer(MacSigner::new(&session_key)); + + // Step 3: Verify the key with the server + let mfa = verify_session_key(client_info, &session_key, &mac_rest).await?; + + // Step 4: Submit 2FA code if needed + if let Some(mfa) = mfa { + let outcome = perform_second_factor_authentication( + &mfa, + client_info, + &session_key, + attempt, + ui, + &mac_rest, + ) + .await?; + + match outcome { + MfaOutcome::Verified => {} + MfaOutcome::BadOtp => return Ok(LoginOutcome::BadOtp), + } + } + + Ok(LoginOutcome::Success(Box::new(Session::new( + session_key, + mac_rest, + )))) +} + +/// Starts a new session, looping through device registration/reauthorization until the server +/// returns SRP parameters. +async fn start_new_session( + credentials: &Credentials, + account_key: &AccountKey, + client_info: &ClientInfo, + rest: &RestClient, +) -> Result<(String, SrpInfo), OnePasswordError> { + let mut device_attempts = 0; + loop { + // Step 1: Request to initiate a new session + let response: NewSession = rest + .post_json( + AUTH_START_ENDPOINT, + json!({ + "email": credentials.username, + "skformat": account_key.format, + "skid": account_key.uuid, + "deviceUuid": client_info.device_uuid, + }), + ) + .await?; + + // Step 2: We could be either done at this point, or the server could ask us to register or + // reauthorize the device. + match response.status.as_str() { + // Done. For a previously unknown device ID this should never happen on a first try, + // though. + "ok" => { + if response.key_format.as_deref() != Some(account_key.format.as_str()) + || response.key_uuid.as_deref() != Some(account_key.uuid.as_str()) + { + return Err(OnePasswordError::BadCredentials); + } + + let auth = response.auth.ok_or_else(|| { + OnePasswordError::Internal( + "missing SRP parameters in the start response".into(), + ) + })?; + let srp_info = SrpInfo::new( + auth.method, + auth.algorithm, + auth.iterations, + decode64_loose(&auth.salt)?, + )?; + return Ok((response.session_id, srp_info)); + } + // "Device deleted" should never really happen, unless we managed to guess a device UUID + // that was previously registered and then deleted. Unlikely. + status @ ("device-not-registered" | "device-deleted") => { + device_attempts += 1; + if device_attempts > MAX_DEVICE_ATTEMPTS { + return Err(OnePasswordError::Internal(format!( + "the server still reports the device as '{status}' after \ + {MAX_DEVICE_ATTEMPTS} attempts" + ))); + } + + let session_rest = rest.with_session_id(&response.session_id)?; + if status == "device-not-registered" { + register_device(client_info, &session_rest).await?; + } else { + reauthorize_device(client_info, &session_rest).await?; + } + } + other => { + return Err(OnePasswordError::Internal(format!( + "failed to start a new session, unsupported status '{other}'" + ))); + } + } + } +} + +/// Completes authentication over the MAC-signed, encrypted channel, returning the enabled 2FA +/// methods when the account needs a second factor. +async fn verify_session_key( + client_info: &ClientInfo, + session_key: &AesKey, + rest: &RestClient, +) -> Result, OnePasswordError> { + let params = json!({ + "client": client_info.client_id(), + "device": client_info.device_body(), + }); + let response: AuthComplete = rest + .post_encrypted_json(AUTH_COMPLETE_ENDPOINT, params, session_key) + .await?; + Ok(response.mfa) +} + +#[cfg(test)] +mod tests { + use bitwarden_api_base::new_http_client; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers}; + + use super::*; + + fn client(server: &MockServer) -> RestClient { + RestClient::new( + new_http_client(), + format!("http://{}/api", server.address()), + "client-id", + "user-agent", + "op-user-agent", + ) + .expect("valid headers") + } + + fn credentials() -> Credentials { + Credentials { + username: "user@example.com".into(), + password: "password".into(), + account_key: "A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9".into(), + domain: "my.1password.com".into(), + device_uuid: "device-uuid".into(), + } + } + + fn account_key() -> AccountKey { + AccountKey::parse(&credentials().account_key).expect("valid account key") + } + + fn start_response(status: &str) -> serde_json::Value { + json!({"status": status, "sessionID": "SESSION"}) + } + + fn ok_start_response() -> serde_json::Value { + json!({ + "status": "ok", + "sessionID": "SESSION", + "accountKeyFormat": "A3", + "accountKeyUuid": "RTN9SA", + "userAuth": { + "method": "SRPg-4096", + "alg": "PBES2g-HS256", + "iterations": 10, + "salt": "c2FsdHNhbHRzYWx0", + }, + }) + } + + #[tokio::test] + async fn fetches_the_auth_methods() { + let server = MockServer::start().await; + server + .register( + Mock::given(matchers::path("/api/v2/auth/methods")) + .and(matchers::body_json(json!({"email": "user@example.com"}))) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({"authMethods": [{"type": "PASSWORD+SK"}]})), + ) + .expect(1), + ) + .await; + + let info = fetch_auth_methods("user@example.com", &client(&server)) + .await + .expect("methods are listed"); + + assert_eq!(info.auth_methods[0].kind, "PASSWORD+SK"); + server.verify().await; + } + + #[tokio::test] + async fn start_registers_an_unknown_device_then_retries() { + let server = MockServer::start().await; + // The first start says the device is unknown, the second succeeds. wiremock matches the + // most recently registered mock first, so register the success last. + server + .register( + Mock::given(matchers::path("/api/v3/auth/start")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(start_response("device-not-registered")), + ) + .up_to_n_times(1) + .expect(1), + ) + .await; + server + .register( + Mock::given(matchers::path("/api/v1/device")) + .and(matchers::method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"success": 1}))) + .expect(1), + ) + .await; + server + .register( + Mock::given(matchers::path("/api/v3/auth/start")) + .respond_with(ResponseTemplate::new(200).set_body_json(ok_start_response())) + .expect(1), + ) + .await; + + let (session_id, srp_info) = start_new_session( + &credentials(), + &account_key(), + &ClientInfo::for_desktop("device-uuid"), + &client(&server), + ) + .await + .expect("session starts after registering the device"); + + assert_eq!(session_id, "SESSION"); + assert_eq!( + srp_info, + SrpInfo::new( + "SRPg-4096".into(), + "PBES2g-HS256".into(), + 10, + b"saltsaltsalt".to_vec(), + ) + .expect("supported parameters") + ); + server.verify().await; + } + + #[tokio::test] + async fn start_gives_up_when_the_device_never_registers() { + let server = MockServer::start().await; + server + .register( + Mock::given(matchers::path("/api/v3/auth/start")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(start_response("device-not-registered")), + ) + .expect(u64::from(MAX_DEVICE_ATTEMPTS) + 1), + ) + .await; + server + .register( + Mock::given(matchers::path("/api/v1/device")) + .and(matchers::method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"success": 1}))) + .expect(u64::from(MAX_DEVICE_ATTEMPTS)), + ) + .await; + + let error = start_new_session( + &credentials(), + &account_key(), + &ClientInfo::for_desktop("device-uuid"), + &client(&server), + ) + .await + .expect_err("gives up instead of registering the device forever"); + + assert!( + error.to_string().contains("device-not-registered"), + "unexpected error: {error}" + ); + server.verify().await; + } + + #[tokio::test] + async fn start_rejects_a_mismatching_account_key() { + let server = MockServer::start().await; + server + .register( + Mock::given(matchers::path("/api/v3/auth/start")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "status": "ok", + "sessionID": "SESSION", + "accountKeyFormat": "A3", + "accountKeyUuid": "OTHERS", + }))) + .expect(1), + ) + .await; + + let error = start_new_session( + &credentials(), + &account_key(), + &ClientInfo::for_desktop("device-uuid"), + &client(&server), + ) + .await + .expect_err("the server knows a different Secret Key"); + + assert!(matches!(error, OnePasswordError::BadCredentials)); + server.verify().await; + } + + #[tokio::test] + async fn start_reports_an_unknown_status() { + let server = MockServer::start().await; + server + .register( + Mock::given(matchers::path("/api/v3/auth/start")) + .respond_with( + ResponseTemplate::new(200).set_body_json(start_response("who-knows")), + ) + .expect(1), + ) + .await; + + let error = start_new_session( + &credentials(), + &account_key(), + &ClientInfo::for_desktop("device-uuid"), + &client(&server), + ) + .await + .expect_err("unknown status"); + + assert!(error.to_string().contains("who-knows")); + server.verify().await; + } +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/mac.rs b/crates/bitwarden-importers/src/importers/onepassword/access/mac.rs new file mode 100644 index 000000000..a71537543 --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/mac.rs @@ -0,0 +1,167 @@ +//! `X-AgileBits-MAC` request signer. + +use std::sync::atomic::{AtomicU32, Ordering}; + +use data_encoding::BASE64URL_NOPAD; +use hmac::{Hmac, KeyInit, Mac}; +use rand::Rng; +use sha2::Sha256; +use url::Url; + +use super::{error::OnePasswordError, opdata::AesKey}; + +const SESSION_HMAC_SECRET: &str = "He never wears a Mac, in the pouring rain. Very strange."; + +/// Signs requests with the per-session MAC header, bumping the request id on each signature. +pub(super) struct MacSigner { + session_id: String, + salt: [u8; 32], + request_id: AtomicU32, +} + +impl MacSigner { + /// Creates a signer from the session key, starting from a random request id. + pub(super) fn new(session_key: &AesKey) -> MacSigner { + Self::with_request_id(session_key, bitwarden_random::rng().next_u32()) + } + + fn with_request_id(session_key: &AesKey, request_id: u32) -> MacSigner { + MacSigner { + session_id: session_key.id.clone(), + salt: calculate_session_hmac_salt(&session_key.key), + request_id: AtomicU32::new(request_id), + } + } + + /// Returns the `X-AgileBits-MAC` header value for a request and takes the next request id. + /// + /// The id only has to differ between requests, so nothing needs ordering against other threads. + pub(super) fn sign(&self, url: &str, method: &str) -> Result { + let id = self.request_id.fetch_add(1, Ordering::Relaxed); + + let message = self.calculate_auth_message(url, method, id)?; + Ok(calculate_auth_signature(&self.salt, &message, id)) + } + + /// `sessionId|METHOD|host/path?query|v1|requestId`. + fn calculate_auth_message( + &self, + url: &str, + method: &str, + request_id: u32, + ) -> Result { + let parsed = Url::parse(url) + .map_err(|_| OnePasswordError::Internal(format!("invalid url '{url}'")))?; + let host = parsed.host_str().unwrap_or(""); + let path = parsed.path().trim_start_matches('/'); + let query = parsed.query().unwrap_or(""); + + Ok(format!( + "{}|{}|{}/{}?{}|v1|{}", + self.session_id, + method.to_uppercase(), + host, + path, + query, + request_id + )) + } +} + +/// `v1|requestId|b64url(HMAC-SHA256(salt, message)[0..12])`. +fn calculate_auth_signature(salt: &[u8], auth_message: &str, request_id: u32) -> String { + let hash = hmac_sha256(salt, auth_message.as_bytes()); + let hash12 = BASE64URL_NOPAD.encode(&hash[..12]); + format!("v1|{request_id}|{hash12}") +} + +/// `HMAC-SHA256(sessionKey, secret)`. +fn calculate_session_hmac_salt(session_key: &[u8]) -> [u8; 32] { + hmac_sha256(session_key, SESSION_HMAC_SECRET.as_bytes()) +} + +fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] { + let mut mac = + as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length"); + mac.update(message); + mac.finalize().into_bytes().into() +} + +#[cfg(test)] +mod tests { + use data_encoding::HEXLOWER; + + use super::{super::opdata::decode64_loose, *}; + + const SESSION_KEY: &str = "WyICHHlP5lPigZUGZYoivbJMqgHjSti86UKwdjCryYM"; + + fn signer() -> MacSigner { + let key = AesKey::new( + "PBXONDZUWVCJFAV25C7XR7IYDQ", + decode64_loose(SESSION_KEY).expect("valid key"), + ); + MacSigner::with_request_id(&key, 842346063) + } + + #[test] + fn sign_returns_headers_with_signature() { + let signature = signer() + .sign("https://my.1password.com/api/v1/auth/verify", "POST") + .expect("signs"); + assert_eq!(signature, "v1|842346063|xv-fEAYowunpH4V-"); + } + + #[test] + fn sign_returns_signature_for_url_with_query() { + let signature = signer() + .sign( + "https://my.1password.com/api/v1/account?attrs=billing,counts,groups,invite,me,settings,tier,user-flags,users,vaults", + "GET", + ) + .expect("signs"); + assert_eq!(signature, "v1|842346063|UyjKq0HAmjB5j7kF"); + } + + #[test] + fn sign_increments_the_counter() { + let signer = signer(); + let first = signer + .sign("https://my.1password.com/api/v1/auth/verify", "POST") + .expect("signs"); + let second = signer + .sign("https://my.1password.com/api/v1/auth/verify", "POST") + .expect("signs"); + + assert_ne!(first, second); + + let seed = |s: &str| -> u32 { + s.split('|') + .nth(1) + .expect("seed field") + .parse() + .expect("u32") + }; + assert_eq!(seed(&first) + 1, seed(&second)); + } + + #[test] + fn calculates_session_hmac_salt() { + let key = decode64_loose(SESSION_KEY).expect("valid key"); + assert_eq!( + HEXLOWER.encode(&calculate_session_hmac_salt(&key)), + "cce080cc9b3eaeaa9b6e621e1b4c4d2048babe16e40b0576fc2520c26473b9ac" + ); + } + + #[test] + fn new_starts_from_a_random_request_id() { + let key = AesKey::new( + "PBXONDZUWVCJFAV25C7XR7IYDQ", + decode64_loose(SESSION_KEY).expect("valid key"), + ); + let ids: Vec = (0..4) + .map(|_| MacSigner::new(&key).request_id.into_inner()) + .collect(); + assert!(ids.iter().any(|id| *id != ids[0])); + } +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/mod.rs b/crates/bitwarden-importers/src/importers/onepassword/access/mod.rs new file mode 100644 index 000000000..45650b15b --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/mod.rs @@ -0,0 +1,34 @@ +//! Read access to a 1Password account: log in with the master password and Secret Key, then +//! download and decrypt every accessible vault into a native 1Password model. +//! +//! A port of the OnePassword module in Bitwarden's C# `password-manager-access` library. See +//! `README.md` for the porting notes and open questions. + +mod account_key; +mod client; +pub use client::Client; +mod credentials; +pub use credentials::Credentials; +mod device; +pub use device::generate_device_uuid; +mod error; +pub use error::OnePasswordError; +mod identity; +mod kdf; +mod keychain; +mod login; +mod mac; +pub mod model; +pub use model::{Item, ItemCategory, Vault}; +mod opdata; +mod region; +pub use region::Region; +mod rest; +mod rsa; +mod session; +mod srp; +mod two_factor; +pub use two_factor::{TotpResult, TwoFactorUi}; +// The DTO fields are named after the JSON keys they carry; documenting each one adds nothing. +#[allow(missing_docs)] +pub mod wire; diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/model.rs b/crates/bitwarden-importers/src/importers/onepassword/access/model.rs new file mode 100644 index 000000000..46de272eb --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/model.rs @@ -0,0 +1,145 @@ +//! What a download yields: vaults holding items, each item still in its decrypted 1Password shape. +//! +//! Mapping these onto Bitwarden ciphers is the importer's job and happens elsewhere. + +use std::fmt; + +use super::wire::{VaultItemDetails, VaultItemOverview}; + +/// A decrypted vault with its items. +#[derive(Debug)] +pub struct Vault { + /// The vault's 1Password uuid. + pub id: String, + /// The vault's display name. + pub name: String, + /// The vault's description, empty when unset. + pub description: String, + /// Every item in the vault except the trashed ones. + pub items: Vec, +} + +/// A decrypted item: its identity plus both payloads exactly as 1Password sends them. +#[derive(Debug)] +pub struct Item { + /// The item's 1Password uuid. + pub id: String, + /// The item's category, derived from its template id. + pub category: ItemCategory, + /// The decrypted `encOverview`: title, subtitle, websites, tags. + pub overview: VaultItemOverview, + /// The decrypted `encDetails`: login fields, sections, note, password history. + pub details: VaultItemDetails, +} + +/// The kind of a vault item, mapped from its template id. The ids are 1Password's standard +/// category template UUIDs; an unrecognized id is preserved as [`ItemCategory::Unknown`] so nothing +/// is lost. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ItemCategory { + /// Template `001`. + Login, + /// Template `002`. + CreditCard, + /// Template `003`. + SecureNote, + /// Template `004`. + Identity, + /// Template `005`. + Password, + /// Template `006`. + Document, + /// Template `100`. + SoftwareLicense, + /// Template `101`. + BankAccount, + /// Template `102`. + Database, + /// Template `103`. + DriverLicense, + /// Template `104`. + OutdoorLicense, + /// Template `105`. + Membership, + /// Template `106`. + Passport, + /// Template `107`. + RewardProgram, + /// Template `108`. + SocialSecurityNumber, + /// Template `109`. + WirelessRouter, + /// Template `110`. + Server, + /// Template `111`. + EmailAccount, + /// Template `112`. + ApiCredential, + /// Template `113`. + MedicalRecord, + /// Template `114`. + SshKey, + /// A template id this crate does not know, kept verbatim. + Unknown(String), +} + +impl ItemCategory { + /// Maps a 1Password template id to a category. Extends the `TemplateId` handling in + /// `Client.ConvertVaultItem` to the full standard template set. + pub(super) fn from_template_id(id: &str) -> ItemCategory { + match id { + "001" => ItemCategory::Login, + "002" => ItemCategory::CreditCard, + "003" => ItemCategory::SecureNote, + "004" => ItemCategory::Identity, + "005" => ItemCategory::Password, + "006" => ItemCategory::Document, + "100" => ItemCategory::SoftwareLicense, + "101" => ItemCategory::BankAccount, + "102" => ItemCategory::Database, + "103" => ItemCategory::DriverLicense, + "104" => ItemCategory::OutdoorLicense, + "105" => ItemCategory::Membership, + "106" => ItemCategory::Passport, + "107" => ItemCategory::RewardProgram, + "108" => ItemCategory::SocialSecurityNumber, + "109" => ItemCategory::WirelessRouter, + "110" => ItemCategory::Server, + "111" => ItemCategory::EmailAccount, + "112" => ItemCategory::ApiCredential, + "113" => ItemCategory::MedicalRecord, + "114" => ItemCategory::SshKey, + other => ItemCategory::Unknown(other.to_string()), + } + } +} + +impl fmt::Display for ItemCategory { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + ItemCategory::Login => "Login", + ItemCategory::CreditCard => "Credit Card", + ItemCategory::SecureNote => "Secure Note", + ItemCategory::Identity => "Identity", + ItemCategory::Password => "Password", + ItemCategory::Document => "Document", + ItemCategory::SoftwareLicense => "Software License", + ItemCategory::BankAccount => "Bank Account", + ItemCategory::Database => "Database", + ItemCategory::DriverLicense => "Driver License", + ItemCategory::OutdoorLicense => "Outdoor License", + ItemCategory::Membership => "Membership", + ItemCategory::Passport => "Passport", + ItemCategory::RewardProgram => "Reward Program", + ItemCategory::SocialSecurityNumber => "Social Security Number", + ItemCategory::WirelessRouter => "Wireless Router", + ItemCategory::Server => "Server", + ItemCategory::EmailAccount => "Email Account", + ItemCategory::ApiCredential => "API Credential", + ItemCategory::MedicalRecord => "Medical Record", + ItemCategory::SshKey => "SSH Key", + ItemCategory::Unknown(id) => return write!(f, "Unknown({id})"), + }; + f.write_str(name) + } +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/opdata.rs b/crates/bitwarden-importers/src/importers/onepassword/access/opdata.rs new file mode 100644 index 000000000..363fec243 --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/opdata.rs @@ -0,0 +1,259 @@ +//! AES-256-GCM "opdata" envelope with the tag appended after the ciphertext. +//! +//! Built on the `aes-gcm` crate and validated below against the IEEE 802.1 GCM test vectors. The +//! tag is the trailing 16 bytes of the ciphertext, exactly as `aes-gcm` lays it out. + +use aes_gcm::{ + Aes256Gcm, KeyInit, + aead::{Aead, Nonce, Payload}, +}; +use data_encoding::BASE64URL_NOPAD; +use zeroize::Zeroize; + +use super::{error::OnePasswordError, wire::EncryptedEnvelope}; + +const ENCRYPTION_SCHEME: &str = "A256GCM"; +const CONTAINER_TYPE: &str = "b5+jwk+json"; + +/// AES-256-GCM encrypt, returning `ciphertext || tag`. +pub(super) fn encrypt( + key: &[u8], + plaintext: &[u8], + iv: &[u8], + aad: &[u8], +) -> Result, OnePasswordError> { + let cipher = Aes256Gcm::new_from_slice(key) + .map_err(|_| OnePasswordError::Internal("the key must be 32 bytes long".into()))?; + let nonce = Nonce::::try_from(iv) + .map_err(|_| OnePasswordError::Internal("the iv must be 12 bytes long".into()))?; + cipher + .encrypt( + &nonce, + Payload { + msg: plaintext, + aad, + }, + ) + .map_err(|_| OnePasswordError::Internal("AES-GCM encryption failed".into())) +} + +/// AES-256-GCM decrypt of `ciphertext || tag`. +pub(super) fn decrypt( + key: &[u8], + ciphertext: &[u8], + iv: &[u8], + aad: &[u8], +) -> Result, OnePasswordError> { + if ciphertext.len() < 16 { + return Err(OnePasswordError::Internal( + "the ciphertext must be at least 16 bytes long".into(), + )); + } + let cipher = Aes256Gcm::new_from_slice(key) + .map_err(|_| OnePasswordError::Internal("the key must be 32 bytes long".into()))?; + let nonce = Nonce::::try_from(iv) + .map_err(|_| OnePasswordError::Internal("the iv must be 12 bytes long".into()))?; + cipher + .decrypt( + &nonce, + Payload { + msg: ciphertext, + aad, + }, + ) + .map_err(|_| OnePasswordError::Internal("the auth tag doesn't match".into())) +} + +/// A decoded envelope: base64 fields turned into bytes. +/// +/// The envelope's container type (`cty`) is dropped: nothing dispatches on it. +#[derive(Debug)] +pub(super) struct Encrypted { + pub key_id: String, + pub scheme: String, + pub iv: Vec, + pub ciphertext: Vec, +} + +impl Encrypted { + /// Decodes the base64 `iv`/`data` fields (the `iv` is optional). + pub(super) fn parse(envelope: &EncryptedEnvelope) -> Result { + Ok(Encrypted { + key_id: envelope.kid.clone(), + scheme: envelope.enc.clone(), + iv: match &envelope.iv { + Some(iv) => decode64_loose(iv)?, + None => Vec::new(), + }, + ciphertext: decode64_loose(&envelope.data)?, + }) + } +} + +/// A symmetric AES-256-GCM key identified by its kid. +pub(super) struct AesKey { + pub id: String, + pub key: Vec, +} + +impl Drop for AesKey { + fn drop(&mut self) { + self.key.zeroize(); + } +} + +impl AesKey { + pub(super) fn new(id: impl Into, key: Vec) -> AesKey { + AesKey { id: id.into(), key } + } + + /// Encrypts `plaintext` into a wire envelope using the given 12-byte IV and empty associated + /// data. + pub(super) fn encrypt( + &self, + plaintext: &[u8], + iv: &[u8], + ) -> Result { + let ciphertext = encrypt(&self.key, plaintext, iv, &[])?; + Ok(EncryptedEnvelope { + kid: self.id.clone(), + enc: ENCRYPTION_SCHEME.to_string(), + cty: CONTAINER_TYPE.to_string(), + iv: Some(BASE64URL_NOPAD.encode(iv)), + data: BASE64URL_NOPAD.encode(&ciphertext), + }) + } + + /// Decrypts an envelope encrypted for this key, with empty associated data. + pub(super) fn decrypt(&self, encrypted: &Encrypted) -> Result, OnePasswordError> { + if encrypted.key_id != self.id { + return Err(OnePasswordError::Internal("mismatching key id".into())); + } + if encrypted.scheme != ENCRYPTION_SCHEME { + return Err(OnePasswordError::Internal(format!( + "invalid encryption scheme '{}', expected '{ENCRYPTION_SCHEME}'", + encrypted.scheme + ))); + } + decrypt(&self.key, &encrypted.ciphertext, &encrypted.iv, &[]) + } +} + +/// Decodes URL-safe, standard, or mixed base64 with or without padding. +pub(super) fn decode64_loose(s: &str) -> Result, OnePasswordError> { + let normalized: String = s + .trim_end_matches('=') + .chars() + .map(|c| match c { + '-' => '+', + '_' => '/', + other => other, + }) + .collect(); + data_encoding::BASE64_NOPAD + .decode(normalized.as_bytes()) + .map_err(|_| OnePasswordError::Parse) +} + +#[cfg(test)] +mod tests { + use data_encoding::HEXLOWER; + + use super::*; + + fn hex(s: &str) -> Vec { + HEXLOWER.decode(s.as_bytes()).expect("valid hex") + } + + // Test vectors from + // http://www.ieee802.org/1/files/public/docs2011/bn-randall-test-vectors-0511-v1.pdf + struct Vector { + key: &'static str, + plaintext: &'static str, + iv: &'static str, + adata: &'static str, + ciphertext: &'static str, + tag: &'static str, + } + + const VECTORS: &[Vector] = &[ + Vector { + key: "e3c08a8f06c6e3ad95a70557b23f75483ce33021a9c72b7025666204c69c0b72", + plaintext: "", + iv: "12153524c0895e81b2c28465", + adata: "d609b1f056637a0d46df998d88e5222ab2c2846512153524c0895e8108000f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f30313233340001", + ciphertext: "", + tag: "2f0bc5af409e06d609ea8b7d0fa5ea50", + }, + Vector { + key: "e3c08a8f06c6e3ad95a70557b23f75483ce33021a9c72b7025666204c69c0b72", + plaintext: "08000f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a0002", + iv: "12153524c0895e81b2c28465", + adata: "d609b1f056637a0d46df998d88e52e00b2c2846512153524c0895e81", + ciphertext: "e2006eb42f5277022d9b19925bc419d7a592666c925fe2ef718eb4e308efeaa7c5273b394118860a5be2a97f56ab7836", + tag: "5ca597cdbb3edb8d1a1151ea0af7b436", + }, + Vector { + key: "691d3ee909d7f54167fd1ca0b5d769081f2bde1aee655fdbab80bd5295ae6be7", + plaintext: "08000f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f30313233340004", + iv: "f0761e8dcd3d000176d457ed", + adata: "e20106d7cd0df0761e8dcd3d88e54c2a76d457ed", + ciphertext: "c1623f55730c93533097addad25664966125352b43adacbd61c5ef3ac90b5bee929ce4630ea79f6ce519", + tag: "12af39c2d1fdc2051f8b7b3c9d397ef2", + }, + ]; + + #[test] + fn encrypt_returns_ciphertext() { + for v in VECTORS { + let out = encrypt(&hex(v.key), &hex(v.plaintext), &hex(v.iv), &hex(v.adata)) + .expect("encrypt succeeds"); + assert_eq!(out, hex(&format!("{}{}", v.ciphertext, v.tag))); + } + } + + #[test] + fn decrypt_returns_plaintext() { + for v in VECTORS { + let ciphertext = hex(&format!("{}{}", v.ciphertext, v.tag)); + let out = decrypt(&hex(v.key), &ciphertext, &hex(v.iv), &hex(v.adata)) + .expect("decrypt succeeds"); + assert_eq!(out, hex(v.plaintext)); + } + } + + #[test] + fn decrypt_throws_on_modified_ciphertext() { + let v = &VECTORS[1]; + let mut ciphertext = hex(&format!("{}{}", v.ciphertext, v.tag)); + ciphertext[0] ^= 1; + let err = + decrypt(&hex(v.key), &ciphertext, &hex(v.iv), &hex(v.adata)).expect_err("tampered"); + assert!(err.to_string().contains("auth tag")); + } + + #[test] + fn rejects_invalid_lengths() { + let msg = |r: Result, OnePasswordError>| r.expect_err("invalid").to_string(); + assert!(msg(encrypt(&[0; 13], &[0; 16], &[0; 12], &[])).contains("key must")); + assert!(msg(encrypt(&[0; 32], &[0; 16], &[0; 13], &[])).contains("iv must")); + assert!(msg(decrypt(&[0; 32], &[0; 13], &[0; 12], &[])).contains("ciphertext must")); + assert!(msg(decrypt(&[0; 13], &[0; 16], &[0; 12], &[])).contains("key must")); + assert!(msg(decrypt(&[0; 32], &[0; 16], &[0; 13], &[])).contains("iv must")); + } + + #[test] + fn decrypts_opdata_envelope() { + let master_key = hex("44c38e8fedb84a1ab5ba74ed98dde931f6500ae39c1d9c85e20a7268ab2074f0"); + let key = AesKey::new("mp", master_key); + + let envelope: EncryptedEnvelope = + serde_json::from_str(include_str!("fixtures/encrypted-aes-key.json")) + .expect("valid fixture"); + let encrypted = Encrypted::parse(&envelope).expect("decodes envelope"); + + let plaintext = String::from_utf8(key.decrypt(&encrypted).expect("decrypts")) + .expect("plaintext is utf8"); + assert!(plaintext.contains("szerdhg2ww2ahjo4ilz57x7cce")); + } +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/region.rs b/crates/bitwarden-importers/src/importers/onepassword/access/region.rs new file mode 100644 index 000000000..93efba44b --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/region.rs @@ -0,0 +1,30 @@ +//! The standard 1Password sign-in regions. + +/// One of the three regions 1Password operates. Each stores its accounts in a different +/// jurisdiction, and an account belongs to exactly one of them. +/// +/// A convenience for callers offering a region to pick from: [`Region::domain`] gives the string +/// that goes into [`super::credentials::Credentials::domain`]. An Enterprise account on a custom +/// sign-in domain skips this and supplies that domain directly. +/// +/// See . +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Region { + /// `my.1password.com`, the default. Data hosted in the United States. + Global, + /// `my.1password.eu`. Data hosted in the European Union. + Europe, + /// `my.1password.ca`. Data hosted in Canada. + Canada, +} + +impl Region { + /// The API host for this region. + pub fn domain(&self) -> &'static str { + match self { + Region::Global => "my.1password.com", + Region::Europe => "my.1password.eu", + Region::Canada => "my.1password.ca", + } + } +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/rest.rs b/crates/bitwarden-importers/src/importers/onepassword/access/rest.rs new file mode 100644 index 000000000..14f098df4 --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/rest.rs @@ -0,0 +1,432 @@ +//! reqwest wrapper: identity headers, the MAC signing hook, encrypted GET/POST, and error parsing. + +use rand::Rng; +use reqwest::{ + Method, + header::{HeaderMap, HeaderName, HeaderValue}, +}; +use serde::de::DeserializeOwned; +use serde_json::Value; + +use super::{ + error::OnePasswordError, + mac::MacSigner, + opdata::{AesKey, Encrypted}, + wire::{EncryptedEnvelope, ErrorResponse, FailureReason}, +}; + +const CLIENT_HEADER: &str = "x-agilebits-client"; +const USER_AGENT_HEADER: &str = "user-agent"; +const OP_USER_AGENT_HEADER: &str = "op-user-agent"; +const SESSION_ID_HEADER: &str = "x-agilebits-session-id"; +const MAC_HEADER: &str = "x-agilebits-mac"; +const IV_SIZE: usize = 12; + +/// An HTTP client bound to a base URL, carrying the 1Password identity headers and an optional MAC +/// signer. Requests are signed automatically once a signer is attached. +pub(super) struct RestClient { + http: reqwest::Client, + base_url: String, + headers: HeaderMap, + signer: Option, +} + +impl RestClient { + /// Builds the base client with the identity headers derived from `ClientInfo`. + pub(super) fn new( + http: reqwest::Client, + base_url: impl Into, + client_id: &str, + user_agent: &str, + op_user_agent: &str, + ) -> Result { + let mut headers = HeaderMap::new(); + insert_header(&mut headers, CLIENT_HEADER, client_id)?; + insert_header(&mut headers, USER_AGENT_HEADER, user_agent)?; + insert_header(&mut headers, OP_USER_AGENT_HEADER, op_user_agent)?; + + Ok(RestClient { + http, + base_url: base_url.into(), + headers, + signer: None, + }) + } + + /// Derives a client that adds the session id header. + pub(super) fn with_session_id(&self, session_id: &str) -> Result { + let mut headers = self.headers.clone(); + insert_header(&mut headers, SESSION_ID_HEADER, session_id)?; + + Ok(RestClient { + http: self.http.clone(), + base_url: self.base_url.clone(), + headers, + signer: None, + }) + } + + /// Derives a client that signs every request. The signer carries the session id already, so + /// this keeps whatever headers the receiver has. + pub(super) fn with_signer(&self, signer: MacSigner) -> RestClient { + RestClient { + http: self.http.clone(), + base_url: self.base_url.clone(), + headers: self.headers.clone(), + signer: Some(signer), + } + } + + /// POSTs a JSON body and parses the JSON response. + pub(super) async fn post_json( + &self, + endpoint: &str, + body: Value, + ) -> Result { + self.request_json(Method::POST, endpoint, Some(&body)).await + } + + /// PUTs with no body and parses the JSON response. + pub(super) async fn put( + &self, + endpoint: &str, + ) -> Result { + self.request_json(Method::PUT, endpoint, None).await + } + + /// GETs an opdata envelope, decrypts it, and parses the JSON plaintext. + pub(super) async fn get_encrypted_json( + &self, + endpoint: &str, + session_key: &AesKey, + ) -> Result { + let envelope = self.request_json(Method::GET, endpoint, None).await?; + decrypt_response(envelope, session_key) + } + + /// Encrypts `params`, POSTs the opdata envelope, then decrypts and parses the response. + pub(super) async fn post_encrypted_json( + &self, + endpoint: &str, + params: Value, + session_key: &AesKey, + ) -> Result { + let payload = serde_json::to_vec(¶ms) + .map_err(|_| OnePasswordError::Internal("failed to serialize request".into()))?; + + let mut iv = [0u8; IV_SIZE]; + bitwarden_random::rng().fill_bytes(&mut iv); + let envelope = session_key.encrypt(&payload, &iv)?; + let body = serde_json::to_value(&envelope) + .map_err(|_| OnePasswordError::Internal("failed to serialize envelope".into()))?; + + let response = self + .request_json(Method::POST, endpoint, Some(&body)) + .await?; + decrypt_response(response, session_key) + } + + /// Sends a request and parses the JSON response. + async fn request_json( + &self, + method: Method, + endpoint: &str, + body: Option<&Value>, + ) -> Result { + let text = self.request(method, endpoint, body).await?; + deserialize(text.as_bytes()) + } + + async fn request( + &self, + method: Method, + endpoint: &str, + body: Option<&Value>, + ) -> Result { + let url = format!("{}/{}", self.base_url, endpoint); + + let mut builder = self + .http + .request(method.clone(), &url) + .headers(self.headers.clone()); + if let Some(body) = body { + builder = builder.json(body); + } + if let Some(signer) = &self.signer { + builder = builder.header(MAC_HEADER, signer.sign(&url, method.as_str())?); + } + + let response = builder + .send() + .await + .map_err(|e| OnePasswordError::Network(e.to_string()))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|e| OnePasswordError::Network(e.to_string()))?; + + if !status.is_success() { + return Err(parse_server_error(text.as_bytes()).unwrap_or_else(|| { + OnePasswordError::Internal(format!( + "unexpected response from the server (HTTP {})", + status.as_u16() + )) + })); + } + + Ok(text) + } +} + +/// Decrypts an opdata envelope and parses its JSON plaintext. +fn decrypt_response( + envelope: EncryptedEnvelope, + session_key: &AesKey, +) -> Result { + let plaintext = session_key.decrypt(&Encrypted::parse(&envelope)?)?; + + // A decrypted payload can still be a server error: some errors come back HTTP 200 with the + // error object encrypted. + if let Some(error) = parse_server_error(&plaintext) { + return Err(error); + } + deserialize(&plaintext) +} + +/// Maps a 1Password error body to an `OnePasswordError`, or `None` when the body is not an error. +fn parse_server_error(body: &[u8]) -> Option { + if let Ok(error) = serde_json::from_slice::(body) { + return Some(match error.code { + 102 => OnePasswordError::BadCredentials, + 117 => OnePasswordError::NotFound, + code => OnePasswordError::Internal(format!( + "the server responded with error code {code}: '{}'", + error.message + )), + }); + } + + if let Ok(failure) = serde_json::from_slice::(body) + && !failure.reason.is_empty() + { + return Some(OnePasswordError::Internal(format!( + "the server responded with failure reason: '{}'", + failure.reason + ))); + } + + None +} + +fn deserialize(body: &[u8]) -> Result { + serde_json::from_slice(body).map_err(|_| OnePasswordError::Parse) +} + +fn insert_header( + headers: &mut HeaderMap, + name: &'static str, + value: &str, +) -> Result<(), OnePasswordError> { + let value = HeaderValue::from_str(value) + .map_err(|_| OnePasswordError::Internal(format!("invalid header value for '{name}'")))?; + headers.insert(HeaderName::from_static(name), value); + Ok(()) +} + +#[cfg(test)] +mod tests { + use bitwarden_api_base::new_http_client; + use serde::Deserialize; + use serde_json::json; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers}; + + use super::{super::opdata::decode64_loose, *}; + + #[derive(Debug, Deserialize)] + struct Greeting { + hello: String, + } + + fn client(server: &MockServer) -> RestClient { + RestClient::new( + new_http_client(), + format!("http://{}/api", server.address()), + "1Password for Mac/81210036", + "1Password for Mac/81210036", + "op-user-agent", + ) + .expect("valid headers") + } + + fn session_key() -> AesKey { + AesKey::new( + "SESSION", + decode64_loose("WyICHHlP5lPigZUGZYoivbJMqgHjSti86UKwdjCryYM").expect("valid key"), + ) + } + + #[test] + fn parses_error_bodies() { + let error = parse_server_error(br#"{"errorCode":102,"errorMessage":"nope"}"#) + .expect("recognized error"); + assert!(matches!(error, OnePasswordError::BadCredentials)); + + let error = parse_server_error(br#"{"errorCode":117,"errorMessage":"gone"}"#) + .expect("recognized error"); + assert!(matches!(error, OnePasswordError::NotFound)); + + let error = parse_server_error(br#"{"errorCode":401,"errorMessage":"no auth"}"#) + .expect("recognized error"); + assert!(matches!(error, OnePasswordError::Internal(_))); + assert!(error.to_string().contains("401")); + + let error = parse_server_error(br#"{"reason":"rate limited"}"#).expect("recognized error"); + assert!(error.to_string().contains("rate limited")); + } + + #[test] + fn ignores_non_error_bodies() { + assert!(parse_server_error(br#"{"status":"ok","sessionID":"S"}"#).is_none()); + assert!(parse_server_error(br#"{"mfa":null}"#).is_none()); + } + + #[tokio::test] + async fn post_json_sends_identity_headers() { + let server = MockServer::start().await; + server + .register( + Mock::given(matchers::path("/api/v2/auth/methods")) + .and(matchers::method("POST")) + .and(matchers::header( + "x-agilebits-client", + "1Password for Mac/81210036", + )) + .and(matchers::body_json(json!({"email": "user@example.com"}))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"hello": "you"}))) + .expect(1), + ) + .await; + + let response: Greeting = client(&server) + .post_json("v2/auth/methods", json!({"email": "user@example.com"})) + .await + .expect("request succeeds"); + + assert_eq!(response.hello, "you"); + server.verify().await; + } + + #[tokio::test] + async fn maps_error_responses_to_errors() { + let server = MockServer::start().await; + server + .register( + Mock::given(matchers::path("/api/v2/auth")) + .respond_with(ResponseTemplate::new(401).set_body_json( + json!({"errorCode": 102, "errorMessage": "bad credentials"}), + )) + .expect(1), + ) + .await; + + let error = client(&server) + .post_json::("v2/auth", json!({})) + .await + .expect_err("server rejects"); + + assert!(matches!(error, OnePasswordError::BadCredentials)); + server.verify().await; + } + + #[tokio::test] + async fn signs_requests_once_a_signer_is_attached() { + let server = MockServer::start().await; + server + .register( + Mock::given(matchers::path("/api/v1/auth/verify")) + .and(matchers::header("x-agilebits-session-id", "SESSION")) + .and(matchers::header_regex("x-agilebits-mac", r"^v1\|\d+\|.+$")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"hello": "you"}))) + .expect(1), + ) + .await; + + let key = session_key(); + let rest = client(&server) + .with_session_id(&key.id) + .expect("valid session id") + .with_signer(MacSigner::new(&key)); + let _: Greeting = rest + .post_json("v1/auth/verify", json!({})) + .await + .expect("request succeeds"); + + server.verify().await; + } + + #[tokio::test] + async fn round_trips_an_encrypted_request() { + let key = session_key(); + let response_body = { + let mut iv = [0u8; IV_SIZE]; + bitwarden_random::rng().fill_bytes(&mut iv); + let envelope = key + .encrypt(br#"{"hello":"encrypted"}"#, &iv) + .expect("encrypts"); + serde_json::to_value(&envelope).expect("serializes") + }; + + let server = MockServer::start().await; + server + .register( + Mock::given(matchers::path("/api/v1/auth/mfa")) + .and(matchers::method("POST")) + // The request body is an opdata envelope, not the plaintext params. + .and(matchers::body_partial_json( + json!({"kid": "SESSION", "enc": "A256GCM"}), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(response_body)) + .expect(1), + ) + .await; + + let response: Greeting = client(&server) + .post_encrypted_json("v1/auth/mfa", json!({"totp": {"code": "123456"}}), &key) + .await + .expect("request succeeds"); + + assert_eq!(response.hello, "encrypted"); + server.verify().await; + } + + #[tokio::test] + async fn surfaces_errors_hidden_inside_an_encrypted_response() { + let key = session_key(); + let response_body = { + let mut iv = [0u8; IV_SIZE]; + bitwarden_random::rng().fill_bytes(&mut iv); + let envelope = key + .encrypt(br#"{"errorCode":102,"errorMessage":"nope"}"#, &iv) + .expect("encrypts"); + serde_json::to_value(&envelope).expect("serializes") + }; + + let server = MockServer::start().await; + server + .register( + Mock::given(matchers::path("/api/v1/auth/mfa")) + .respond_with(ResponseTemplate::new(200).set_body_json(response_body)) + .expect(1), + ) + .await; + + let error = client(&server) + .post_encrypted_json::("v1/auth/mfa", json!({}), &key) + .await + .expect_err("encrypted error is surfaced"); + + assert!(matches!(error, OnePasswordError::BadCredentials)); + server.verify().await; + } +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/rsa.rs b/crates/bitwarden-importers/src/importers/onepassword/access/rsa.rs new file mode 100644 index 000000000..19ab0b6c2 --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/rsa.rs @@ -0,0 +1,132 @@ +//! RSA-OAEP (SHA1/SHA256) decrypt from 1Password's JWK. +//! +//! This module shares its name with the external `rsa` crate, so inside the crate the dependency is +//! reached as `::rsa`. + +use ::rsa::{BoxedUint, Oaep, RsaPrivateKey}; +use sha1::Sha1; +use sha2::Sha256; + +use super::{ + error::OnePasswordError, + opdata::{Encrypted, decode64_loose}, + wire::RsaKeyJwk, +}; + +const OAEP_SHA1: &str = "RSA-OAEP"; +const OAEP_SHA256: &str = "RSA-OAEP-256"; + +/// An RSA private key identified by its kid. +pub(super) struct RsaKey { + pub id: String, + key: RsaPrivateKey, +} + +impl RsaKey { + /// Builds the private key from the base64 JWK components. + pub(super) fn parse(jwk: &RsaKeyJwk) -> Result { + let uint = |s: &str| -> Result { + Ok(BoxedUint::from_be_slice_vartime(&decode64_loose(s)?)) + }; + + let key = RsaPrivateKey::from_components( + uint(&jwk.n)?, + uint(&jwk.e)?, + uint(&jwk.d)?, + vec![uint(&jwk.p)?, uint(&jwk.q)?], + ) + .map_err(|_| OnePasswordError::Internal("invalid RSA key".into()))?; + + Ok(RsaKey { + id: jwk.kid.clone(), + key, + }) + } + + /// Decrypts an envelope encrypted for this key, dispatching on the OAEP scheme. + pub(super) fn decrypt(&self, encrypted: &Encrypted) -> Result, OnePasswordError> { + if encrypted.key_id != self.id { + return Err(OnePasswordError::Internal("mismatching key id".into())); + } + + let result = match encrypted.scheme.as_str() { + OAEP_SHA1 => self.key.decrypt(Oaep::::new(), &encrypted.ciphertext), + OAEP_SHA256 => self + .key + .decrypt(Oaep::::new(), &encrypted.ciphertext), + other => { + return Err(OnePasswordError::Internal(format!( + "invalid encryption scheme '{other}'" + ))); + } + }; + + result.map_err(|_| OnePasswordError::Decryption) + } +} + +#[cfg(test)] +mod tests { + use data_encoding::BASE64; + + use super::*; + + const SHA1_CIPHERTEXT: &str = "plF49e+3R0IpxBqWinosrPxWS8GdzKULvo4myIS1Gam5LCl1TmvvtntAiwOaL+/x8Ie7JApxksrpzrg9UAIaJeOJcoSzPA/hT4nn2jnglWLt+Dwz6RiEyQXhHqnyEOZ56RhNrVR8qKrnApUX2J/FWmrSYXQduIM2xbbx1LQwCGJJxCHp/pFf3Eb0fwtaw2AB5QEF5uTXOnOY+NYaPUJLKTX63uas+uPGUtdJP66WT15zHEK/WRx4ekafJvIjueSTaiceq+IVXc5niMzTMYvRb5rIEiNm3WSX7EteqaU9T46ytm9748ILQNeuGSjzIqhO4H7mO47/e8wdEh3WZk8Alg=="; + const SHA256_CIPHERTEXT: &str = "R2wRx7neV9M/hMyWhr6heE43Q48xL+6lZuy9k03+G0FVPmXsVPRK4q7nWq6UDVwcj42nxMychMKfurCuecLEd+h5zum9Py9y6r702GnymQAl0ReM6NyjxW2m1YOp6zFVlqa69Tptn+ewOD1Fqr14yJTgVtcSJCKjQxI0ALrFst/tMvOjMFFtYPCsQ3oC0ka7kDnjbikOD0AL7Q6/19Nilr3C/TjQdNRC1Y3c5sKtyDZj++OkwgB2nac1V9IfLbpum5nqQim4UBOwE8f1axTDSYtKLJ31rr+z5bHxraUMzz96BnOmIzsZ2jj0fHrZBsBUs1L5Bg5XmGwHTz01z4HQ9A=="; + + fn key(fixture: &str) -> RsaKey { + let jwk: RsaKeyJwk = serde_json::from_str(fixture).expect("valid jwk"); + RsaKey::parse(&jwk).expect("valid key") + } + + fn envelope(key_id: &str, scheme: &str, ciphertext: Vec) -> Encrypted { + Encrypted { + key_id: key_id.into(), + scheme: scheme.into(), + iv: Vec::new(), + ciphertext, + } + } + + #[test] + fn parses_sha1_key() { + assert_eq!( + key(include_str!("fixtures/rsa-key.json")).id, + "szerdhg2ww2ahjo4ilz57x7cce" + ); + } + + #[test] + fn parses_sha256_key() { + assert_eq!( + key(include_str!("fixtures/rsa-key-oaep-256.json")).id, + "sfaijsnbchbtznlar7mx6yrhae" + ); + } + + #[test] + fn decrypts_oaep_sha1() { + let key = key(include_str!("fixtures/rsa-key.json")); + let ciphertext = BASE64.decode(SHA1_CIPHERTEXT.as_bytes()).expect("base64"); + let encrypted = envelope("szerdhg2ww2ahjo4ilz57x7cce", "RSA-OAEP", ciphertext); + let plain = key.decrypt(&encrypted).expect("decrypts"); + assert_eq!(plain, b"All your base are belong to us"); + } + + #[test] + fn decrypts_oaep_sha256() { + let key = key(include_str!("fixtures/rsa-key-oaep-256.json")); + let ciphertext = BASE64.decode(SHA256_CIPHERTEXT.as_bytes()).expect("base64"); + let encrypted = envelope("sfaijsnbchbtznlar7mx6yrhae", "RSA-OAEP-256", ciphertext); + let plain = key.decrypt(&encrypted).expect("decrypts"); + assert_eq!(plain, b"All your base are belong to us"); + } + + #[test] + fn rejects_mismatching_key_id() { + let key = key(include_str!("fixtures/rsa-key.json")); + let encrypted = envelope("invalid-id", "RSA-OAEP", b"ciphertext".to_vec()); + let err = key.decrypt(&encrypted).expect_err("mismatch"); + assert!(err.to_string().contains("mismatching key id")); + } +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/session.rs b/crates/bitwarden-importers/src/importers/onepassword/access/session.rs new file mode 100644 index 000000000..d298f68ae --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/session.rs @@ -0,0 +1,15 @@ +//! The signed channel a completed login leaves behind. + +use super::{opdata::AesKey, rest::RestClient}; + +/// The session key and a client that MAC-signs and encrypts every request with it. +pub(super) struct Session { + pub key: AesKey, + pub rest: RestClient, +} + +impl Session { + pub(crate) fn new(key: AesKey, rest: RestClient) -> Session { + Session { key, rest } + } +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/srp.rs b/crates/bitwarden-importers/src/importers/onepassword/access/srp.rs new file mode 100644 index 000000000..e36b2a84a --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/srp.rs @@ -0,0 +1,537 @@ +//! SRP-4096: the A/B exchange with the server and the crypto behind it. + +use std::sync::LazyLock; + +use crypto_bigint::{ + BoxedUint, ConcatenatingMul, Odd, Resize, + modular::{BoxedMontyForm, BoxedMontyParams}, +}; +use data_encoding::{BASE64URL_NOPAD, HEXLOWER}; +use rand::Rng; +use serde_json::json; +use sha2::{Digest, Sha256}; + +use super::{ + account_key::AccountKey, + credentials::Credentials, + error::OnePasswordError, + kdf, + opdata::AesKey, + rest::RestClient, + wire::{AForB, ServerHash}, +}; + +const SRP_METHOD: &str = "SRPg-4096"; +const AUTH_ENDPOINT: &str = "v2/auth"; +const CONFIRM_KEY_ENDPOINT: &str = "v2/auth/confirm-key"; + +/// The width of the SRP group, and so of every value reduced modulo it. +const N_BITS: u32 = 4096; + +/// The width of the SHA-256 values SRP uses as scalars: `u`, `k` and `x`. +const SCALAR_BITS: u32 = 256; + +/// The 4096-bit SRP group prime (RFC 3526). +static N: LazyLock> = LazyLock::new(|| { + Odd::new(BoxedUint::from_be_hex(N_HEX, N_BITS).expect("N_HEX is a compile-time hex constant")) + .expect("the SRP group prime is odd") +}); + +/// The Montgomery form of [`N`], built once. +static N_PARAMS: LazyLock = + LazyLock::new(|| BoxedMontyParams::new_vartime(N.clone())); + +/// The SRP group generator. +static G: LazyLock = LazyLock::new(|| BoxedUint::from(5u32)); + +const N_HEX: &str = concat!( + "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22", + "514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6", + "F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D", + "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB", + "9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3BE39E772C180E8603", + "9B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF6955817183995497CEA956AE515D2261898FA0510", + "15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7D", + "B3970F85A6E1E4C7ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6BF12FFA06D98A0864", + "D87602733EC86A64521F2B18177B200CBBE117577A615D6C770988C0BAD946E208E24FA074E5AB31", + "43DB5BFCE0FD108E4B82D120A92108011A723C12A787E6D788719A10BDBA5B2699C327186AF4E23C", + "1A946834B6150BDA2583E9CA2AD44CE8DBBBC2DB04DE8EF92E8EFC141FBECAA6287C59474E6BC05D", + "99B2964FA090C3A2233BA186515BE7ED1F612970CEE2D7AFB81BDD762170481CD0069127D5B05AA9", + "93B4EA988D8FDDC186FFB7DC90A6C08F4DF435C934063199FFFFFFFFFFFFFFFF", +); + +/// The account's public SRP parameters, as returned by `v3/auth/start`. +/// +/// Validated on construction, so [`compute_x`] cannot fail on an unsupported method or a zero +/// iteration count. These are public KDF parameters and carry no secret. +#[derive(Debug, PartialEq)] +pub(super) struct SrpInfo { + srp_method: String, + key_method: String, + iterations: u32, + salt: Vec, +} + +impl SrpInfo { + /// Rejects parameters this module cannot honour. + pub(super) fn new( + srp_method: String, + key_method: String, + iterations: u32, + salt: Vec, + ) -> Result { + if srp_method != SRP_METHOD { + return Err(OnePasswordError::Unsupported(format!( + "Method '{srp_method}' is not supported" + ))); + } + if iterations == 0 { + return Err(OnePasswordError::Unsupported( + "0 iterations is not supported".into(), + )); + } + + Ok(SrpInfo { + srp_method, + key_method, + iterations, + salt, + }) + } + + /// The salt, also mixed into the client verification hash. + pub(super) fn salt(&self) -> &[u8] { + &self.salt + } +} + +/// Runs the SRP exchange and labels the resulting key with the session id. +pub(super) async fn perform_and_verify( + credentials: &Credentials, + account_key: &AccountKey, + srp_info: &SrpInfo, + session_id: &str, + rest: &RestClient, +) -> Result { + let key = perform( + &generate_secret_a(), + credentials, + account_key, + srp_info, + rest, + ) + .await?; + Ok(AesKey::new(session_id, key.to_vec())) +} + +/// The exchange itself, with `secret_a` taken as an argument so tests can pin it. +async fn perform( + secret_a: &BoxedUint, + credentials: &Credentials, + account_key: &AccountKey, + srp_info: &SrpInfo, + rest: &RestClient, +) -> Result<[u8; 32], OnePasswordError> { + // The password and the Secret Key stretched into the SRP private value. + let srp_x = compute_x(credentials, account_key, srp_info)?; + + // Trade our public ephemeral for the server's. + let shared_a = compute_shared_a(secret_a); + let shared_b = exchange_a_for_b(&shared_a, rest).await?; + + // A B divisible by N would collapse the session key to a value the server picked. + validate_b(&shared_b)?; + + // Both sides reach the same key without the password ever crossing the wire. + let session_key = compute_key(secret_a, &shared_a, &shared_b, &srp_x); + + // Prove to the server that we hold it. + verify_key( + &session_key, + &credentials.username, + &account_key.uuid, + srp_info.salt(), + &shared_a, + &shared_b, + rest, + ) + .await?; + + Ok(session_key) +} + +/// Generates the ephemeral secret `a` as a random 256-bit value. +fn generate_secret_a() -> BoxedUint { + let mut bytes = [0u8; 32]; + bitwarden_random::rng().fill_bytes(&mut bytes); + scalar(&bytes) +} + +/// `A = g^a mod N`. +fn compute_shared_a(secret_a: &BoxedUint) -> BoxedUint { + mod_pow(&G, secret_a) +} + +/// Rejects a server `B` that is a multiple of `N`. +fn validate_b(shared_b: &BoxedUint) -> Result<(), OnePasswordError> { + if bool::from(shared_b.rem(N.as_nz_ref()).is_zero()) { + return Err(OnePasswordError::Internal( + "Shared B validation failed".into(), + )); + } + Ok(()) +} + +/// Sends `userA` and returns the server's `userB`. +async fn exchange_a_for_b( + shared_a: &BoxedUint, + rest: &RestClient, +) -> Result { + let response: AForB = rest + .post_json(AUTH_ENDPOINT, json!({ "userA": to_server_hex(shared_a) })) + .await?; + from_server_hex(&response.b) +} + +/// `base ^ exponent mod N`, constant time in `exponent`. +fn mod_pow(base: &BoxedUint, exponent: &BoxedUint) -> BoxedUint { + BoxedMontyForm::new(base.resize(N_BITS), &N_PARAMS) + .pow(exponent) + .retrieve() +} + +/// A SHA-256 output as an SRP scalar. +fn scalar(hash: &[u8]) -> BoxedUint { + BoxedUint::from_be_slice(hash, SCALAR_BITS).expect("an SRP scalar is a 32-byte hash") +} + +/// Computes the SRP session key `K`. +fn compute_key( + secret_a: &BoxedUint, + shared_a: &BoxedUint, + shared_b: &BoxedUint, + srp_x: &[u8], +) -> [u8; 32] { + // The multiplier k = H(N, g), always + // 3509477ea9fca66eadb7cf7b1bd0eb508f54d3989a9c988006a7d0b338374dd2 for this group. + let mut g_mod_n_input = to_compatible_byte_array(&N); + g_mod_n_input.extend_from_slice(&mod_n_bytes(&G)); + let g_mod_n = sha256(&g_mod_n_input); + + // The scrambling parameter u = H(A, B), which ties the key to both ephemerals. + let mut ab = mod_n_bytes(shared_a); + ab.extend_from_slice(&mod_n_bytes(shared_b)); + let ab_sha256 = sha256(&ab); + + // a + u*x, the half only we can build. + let x = scalar(srp_x); + let exponent = scalar(&ab_sha256) + .concatenating_mul(&x) + .wrapping_add(secret_a); + + // shared_b - k*g^x strips the verifier term out of the server's ephemeral, leaving g^b. The + // difference is almost always negative, so each operand is reduced mod N first and `sub_mod` + // adds N back when the subtraction underflows. + let k_g_pow_x = mod_pow(&G, &x) + .concatenating_mul(&scalar(&g_mod_n)) + .rem(N.as_nz_ref()); + let base = shared_b + .rem(N.as_nz_ref()) + .sub_mod(&k_g_pow_x, N.as_nz_ref()); + + // K is the premaster secret hashed in the server's hex encoding. + sha256(to_server_hex(&mod_pow(&base, &exponent)).as_bytes()) +} + +/// Hex in the exact format 1Password's server expects: lowercase, with all leading zero nibbles +/// stripped. The output may be odd-length; that is intentional. Both `userA` (sent over the wire) +/// and `u` (the SRP shared secret hashed into the session key) use this encoding, and changing it +/// would break wire compatibility or session-key agreement with the server. +/// +/// Mirrors the official 1Password JS client (webapi bundle): +/// `q = e => e.toString(16).replace(/^(0x)?0*/, "")` +fn to_server_hex(value: &BoxedUint) -> String { + let hex = HEXLOWER.encode(&value.to_be_bytes()); + match hex.trim_start_matches('0') { + "" => "0".to_string(), + trimmed => trimmed.to_string(), + } +} + +/// Parses a value the server sent in the encoding above. +fn from_server_hex(hex: &str) -> Result { + let invalid = || OnePasswordError::Internal("invalid shared value from server".into()); + + // The ASCII check is load bearing: padding below counts characters while `from_be_hex` asserts + // on byte length, so a multi-byte character would overshoot the width and panic. + if hex.is_empty() || hex.len() > N_HEX.len() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(invalid()); + } + + // The server strips leading zeros, `from_be_hex` wants the full width of the group. + let padded = format!("{hex:0>width$}", width = N_HEX.len()); + BoxedUint::from_be_hex(&padded, N_BITS) + .into_option() + .ok_or_else(invalid) +} + +/// `value mod N` as big-endian bytes, always the full width of `N`. +fn mod_n_bytes(value: &BoxedUint) -> Vec { + value.rem(N.as_nz_ref()).to_be_bytes().into_vec() +} + +/// Big-endian bytes with leading zeros stripped, the encoding the server hashes over. +fn to_compatible_byte_array(value: &BoxedUint) -> Vec { + let bytes = value.to_be_bytes(); + match bytes.iter().position(|byte| *byte != 0) { + Some(first_significant) => bytes[first_significant..].to_vec(), + None => vec![0], + } +} + +/// `SHA256(SHA256(uuid) || SHA256(lower(username)))`, url-safe base64. +fn calculate_identity(username: &str, key_uuid: &str) -> String { + let mut buffer = Vec::with_capacity(64); + buffer.extend_from_slice(&sha256(key_uuid.as_bytes())); + buffer.extend_from_slice(&sha256(username.to_lowercase().as_bytes())); + BASE64URL_NOPAD.encode(&sha256(&buffer)) +} + +/// Sends the client verification hash to confirm the session key. +async fn verify_key( + session_key: &[u8], + username: &str, + key_uuid: &str, + salt: &[u8], + shared_a: &BoxedUint, + shared_b: &BoxedUint, + rest: &RestClient, +) -> Result<(), OnePasswordError> { + let client_hash = + calculate_client_hash(session_key, username, key_uuid, salt, shared_a, shared_b); + let response: ServerHash = rest + .post_json( + CONFIRM_KEY_ENDPOINT, + json!({ "clientVerifyHash": BASE64URL_NOPAD.encode(&client_hash) }), + ) + .await?; + + // TODO: Verify the server hash here. For now, we trust the server. + if response.server_verify_hash.is_empty() { + return Err(OnePasswordError::Parse); + } + Ok(()) +} + +/// The client verification hash sent to `v2/auth/confirm-key`: +/// `H(H(N) xor H(g) || H(I) || s || A || B || K)`. +fn calculate_client_hash( + session_key: &[u8], + username: &str, + key_uuid: &str, + salt: &[u8], + shared_a: &BoxedUint, + shared_b: &BoxedUint, +) -> [u8; 32] { + let sirp_n = sha256(&to_compatible_byte_array(&N)); + let sirp_g = sha256(&to_compatible_byte_array(&G)); + let identity = sha256(calculate_identity(username, key_uuid).as_bytes()); + + // Opens with the group both sides agreed on. + let mut buffer = Vec::new(); + for (a, b) in sirp_n.iter().zip(sirp_g.iter()) { + buffer.push(a ^ b); + } + + // Then who we are, the salt, both ephemerals, and the key only we and the server derived. + buffer.extend_from_slice(&identity); + buffer.extend_from_slice(salt); + buffer.extend_from_slice(&to_compatible_byte_array(shared_a)); + buffer.extend_from_slice(&to_compatible_byte_array(shared_b)); + buffer.extend_from_slice(session_key); + + sha256(&buffer) +} + +/// Derives SRP `x` from the password and account key. +/// +/// Unlike the master key, the HKDF `info` is the SRP method and the password is used raw (not NFC). +fn compute_x( + credentials: &Credentials, + account_key: &AccountKey, + srp_info: &SrpInfo, +) -> Result<[u8; 32], OnePasswordError> { + let k1 = kdf::hkdf_sha256( + &srp_info.srp_method, + &srp_info.salt, + credentials.username.to_lowercase().as_bytes(), + ); + let k2 = kdf::pbes2( + &srp_info.key_method, + &credentials.password, + &k1, + srp_info.iterations, + )?; + account_key.combine_with(&k2) +} + +fn sha256(data: &[u8]) -> [u8; 32] { + Sha256::digest(data).into() +} + +#[cfg(test)] +mod tests { + use bitwarden_api_base::new_http_client; + use data_encoding::HEXLOWER; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers}; + + use super::*; + + const SHARED_A: &str = "843c9c4977cf9c767452c90708c3dbdf3508c0016f8a56abc20c2e654dbd74c2c04b9412528a0927f499b245f9ad6742052662de2f725bf2a6c84913062842b4b2aaa8d41598c0d11424745bbae928d8e00e3c2c831c5ae90e128b719adb8be3845561186826462f0dbbdba272666c039f075b3da18c866c61a208cb9aed5ade03e6570818b7146c789f2e2928958ec7bebffbf2cc06cbb83b77ed80eae95e194502dead2e945e885d145d4521b74b8669211ffe718b20f04253d19550e0f9e8f1f0381caa2200223904a94d1e70f7db7cfa7d10d415bf7571f656a2e7bac3d142a2fa60b5a4e2fec4a82348fb46e03b65938f960373eefb95e50b1dd38134593b2f3ed0a19ae8684b4b54a04e0e022e01abc03072aa2e0096b209eaadb8dae57acd607a46e27bc5bfa66c3887e03441b4628135f830d1d78c7a60366d88cb42ed7ddd2dc32049f9dd3a1f459b610d41d25e8615f3271fcadcd37bf1b13c84c049d57d14ded500290b430c33d1d1dc3b04af66862ca3b4d501e2827355f68eaaf063a131c2436aa0a75519b7ac4d79845b6235898dcd9bef1093618b7c5bc5d73a7fc2a5ef8bca638e922152e459e89652b4a7d7d19cfd24de93f72f20e3a6f4325abf5ca1aec3ef3f392cc356c80b72e43a577775d2bf613b60d9f46d130e9881534e7548241e612901f61d5c5acb62100b8371c8dc42747437cd9ddcf9debf"; + const SHARED_B: &str = "7112742e3035eca37656e1ad2171516e3e154bcbabbcb5f52787aa53ad882cffd8e952bd67dbc8059025be23a0b86914bf8ec4c08cac0b3448a99d8c097e4b0c6942870b2cd2c56a58499c81c294bf2f64de408535f0a36ba416177519dcb5a54b7a403459abb1bfe8aecb92048e84a55ba48f1672f6ee3f30abff81868e88c8bb25c7c17292e535f91debda167af8f12d1e1073a48a9257b443dacd8ba47270051b03940117d2cec29f6521a3e78e575634db5bc87d479a4327db1b30578c90553edd3de58af08e9157a11b352b0bd7fca70d469809b3d516fed4edc989b78c6f330e553947111c563cb8c8ff184179cf7b8733494e16f3e38ed7cd42651c5bb4d81548c4b320996445b6f1a4c34a6211b5f65e561c04009c7422e289d7035085e21258513040b16bea0d3e91304879fa61f48af4daefce65d0917e4af106d868c6189dfd9031c8a3b2d97fa2a50445d6a818341fed7ad2a986f5aa691626426dc2b1047e1db8a1984f8fda526f21e825df6b4cc60cd31300181a3782e53d039f85164e417b419cde581826b08887f25277f9f7c0933aa596f5a4bb27af7bffb095027e326d1c02544357eaa553ac93b564bb5953b8fc498044d65b8003ad93f95c319ce6af0a0327151935e860c3e5dad17cd65ae4318e76905ce2a3ae239c12ab207313af3c0c7744e7aee2584043ae71dfc3e376bf747f92fa5a94bd36cb"; + + fn big(hex: &str) -> BoxedUint { + from_server_hex(hex).expect("valid hex") + } + + #[test] + fn to_server_hex_returns_hex_string() { + let cases: [(u32, &str); 8] = [ + (0, "0"), + (1, "1"), + (0xD, "d"), + (0xDE, "de"), + (0xDEA, "dea"), + (0xDEAD, "dead"), + (0x80, "80"), + (0xFF, "ff"), + ]; + for (number, expected) in cases { + assert_eq!(to_server_hex(&BoxedUint::from(number)), expected); + } + } + + #[test] + fn from_server_hex_rejects_malformed_values() { + for input in ["", "not hex", "é", &"f".repeat(N_HEX.len() + 1)] { + from_server_hex(input).expect_err("malformed values are rejected"); + } + } + + #[test] + fn compute_key_returns_key() { + let secret_a = BoxedUint::from_be_hex( + "37bbf7bf6a51f902673556ea6a2db91dd9987554ab74c3bc089b213693d9c06e", + SCALAR_BITS, + ) + .expect("valid hex"); + let srp_x = HEXLOWER + .decode(b"9559afc0581390b1190a57dd281729baa237760982c7369c4c14d42157703a0f") + .expect("valid hex"); + + let key = compute_key(&secret_a, &big(SHARED_A), &big(SHARED_B), &srp_x); + + assert_eq!( + HEXLOWER.encode(&key), + "9d17458228928fc1107668113026390d502a40954e3e6a83513acbb2e1f8fedc" + ); + } + + #[test] + fn calculate_client_hash_returns_hash() { + let session_key = HEXLOWER + .decode(b"9d17458228928fc1107668113026390d502a40954e3e6a83513acbb2e1f8fedc") + .expect("valid hex"); + let salt = HEXLOWER + .decode(b"c813e48eb6e88c7557c9a70fcbda0fbc") + .expect("valid hex"); + + let hash = calculate_client_hash( + &session_key, + "user@example.com", + "P9JQCW", + &salt, + &big(SHARED_A), + &big(SHARED_B), + ); + + assert_eq!( + HEXLOWER.encode(&hash), + "e74d30467ccdfdf7d61973b9a94f88bd2b7155ba304138f5d02e2078c3a124fa" + ); + } + + #[test] + fn compute_x_returns_x() { + let salt = + super::super::opdata::decode64_loose("-JLqTVQLjQg08LWZ0gyuUA").expect("valid salt"); + let account_key = + AccountKey::parse("A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9").expect("valid account key"); + + let srp_info = SrpInfo::new("SRPg-4096".into(), "PBES2g-HS256".into(), 100000, salt) + .expect("supported parameters"); + + let credentials = Credentials { + username: "username".into(), + password: "password".into(), + account_key: "A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9".into(), + domain: "my.1password.com".into(), + device_uuid: "device-uuid".into(), + }; + + let x = compute_x(&credentials, &account_key, &srp_info).expect("derivation succeeds"); + + assert_eq!( + HEXLOWER.encode(&x), + "e7e14f282b01332cc193dc42f8501e3ffe8afdbf4b431ed4bfd885ff0bdfecf3" + ); + } + + #[tokio::test] + async fn verify_key_requires_a_server_hash() { + let server = MockServer::start().await; + server + .register( + Mock::given(matchers::path("/api/v2/auth/confirm-key")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({"serverVerifyHash": ""})), + ) + .expect(1), + ) + .await; + let rest = RestClient::new( + new_http_client(), + format!("http://{}/api", server.address()), + "client-id", + "user-agent", + "op-user-agent", + ) + .expect("valid headers"); + + let error = verify_key( + &[0u8; 32], + "user@example.com", + "RTN9SA", + b"salt", + &BoxedUint::from(2u32), + &BoxedUint::from(3u32), + &rest, + ) + .await + .expect_err("an empty hash is rejected"); + + assert!(matches!(error, OnePasswordError::Parse)); + server.verify().await; + } + + #[test] + fn srp_info_rejects_unsupported_parameters() { + let bad_method = SrpInfo::new("SRPg-2048".into(), "PBES2g-HS256".into(), 100000, vec![]) + .expect_err("only SRPg-4096 is supported"); + assert!(bad_method.to_string().contains("SRPg-2048")); + + let no_iterations = SrpInfo::new("SRPg-4096".into(), "PBES2g-HS256".into(), 0, vec![]) + .expect_err("0 iterations is rejected"); + assert!(no_iterations.to_string().contains("0 iterations")); + } +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/two_factor.rs b/crates/bitwarden-importers/src/importers/onepassword/access/two_factor.rs new file mode 100644 index 000000000..ac486ab20 --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/two_factor.rs @@ -0,0 +1,252 @@ +//! Two-factor authentication: the callback the caller implements and the TOTP exchange that +//! drives it. +//! +//! Only TOTP (Google Authenticator) is implemented. WebAuthn and Duo extend [`TwoFactorUi`] later. + +use async_trait::async_trait; +use serde::de::IgnoredAny; +use serde_json::json; + +use super::{ + device::ClientInfo, error::OnePasswordError, opdata::AesKey, rest::RestClient, wire::MfaInfo, +}; + +const MFA_ENDPOINT: &str = "v1/auth/mfa"; + +/// The outcome of a two-factor prompt. +pub enum TotpResult { + /// A passcode entered (or generated) by the user. + Code(String), + /// The user declined to provide a passcode. + Cancel, +} + +/// Callback for interactive two-factor authentication. +#[async_trait] +pub trait TwoFactorUi: Send + Sync { + /// Provides a TOTP passcode for the given zero-based attempt. Each wrong code restarts the + /// login, so `attempt` grows as the user retries. + async fn provide_totp(&self, attempt: u32) -> TotpResult; +} + +/// The result of submitting a second factor: verified, or a rejected code that asks for a restart. +#[derive(Debug)] +pub(super) enum MfaOutcome { + /// The code was accepted and the session is authenticated. + Verified, + /// The code was rejected. 1Password invalidates the session, so the login has to start over. + BadOtp, +} + +/// Prompts for and submits a TOTP code. WebAuthn and Duo are not supported yet. +pub(super) async fn perform_second_factor_authentication( + mfa: &MfaInfo, + client_info: &ClientInfo, + session_key: &AesKey, + attempt: u32, + ui: &dyn TwoFactorUi, + rest: &RestClient, +) -> Result { + if !mfa.totp_enabled() { + return Err(OnePasswordError::Unsupported(format!( + "account requires an unsupported 2FA method (offered: {})", + mfa.enabled_methods().join(", ") + ))); + } + + let passcode = match ui.provide_totp(attempt).await { + TotpResult::Code(passcode) => passcode, + TotpResult::Cancel => return Err(OnePasswordError::TwoFactorFailed), + }; + + match submit_totp(client_info, session_key, &passcode, rest).await { + Ok(()) => Ok(MfaOutcome::Verified), + // 1Password reports a wrong code as a generic auth error; treat it as a retryable bad code. + Err(OnePasswordError::BadCredentials) => Ok(MfaOutcome::BadOtp), + Err(error) => Err(error), + } +} + +/// Submits a TOTP code to `v1/auth/mfa`. The remember-me token in the response is ignored (one-shot +/// import). +async fn submit_totp( + client_info: &ClientInfo, + session_key: &AesKey, + passcode: &str, + rest: &RestClient, +) -> Result<(), OnePasswordError> { + let params = json!({ + "sessionID": session_key.id, + "client": client_info.client_id(), + "totp": { "code": passcode }, + }); + let _: IgnoredAny = rest + .post_encrypted_json(MFA_ENDPOINT, params, session_key) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use bitwarden_api_base::new_http_client; + use rand::Rng; + use serde_json::Value; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers}; + + use super::{super::opdata::decode64_loose, *}; + + struct ScriptedUi { + result: TotpResult, + } + + impl ScriptedUi { + fn code(passcode: &str) -> ScriptedUi { + ScriptedUi { + result: TotpResult::Code(passcode.into()), + } + } + + fn cancel() -> ScriptedUi { + ScriptedUi { + result: TotpResult::Cancel, + } + } + } + + #[async_trait] + impl TwoFactorUi for ScriptedUi { + async fn provide_totp(&self, _attempt: u32) -> TotpResult { + match &self.result { + TotpResult::Code(passcode) => TotpResult::Code(passcode.clone()), + TotpResult::Cancel => TotpResult::Cancel, + } + } + } + + fn session_key() -> AesKey { + AesKey::new( + "SESSION", + decode64_loose("WyICHHlP5lPigZUGZYoivbJMqgHjSti86UKwdjCryYM").expect("valid key"), + ) + } + + fn client(server: &MockServer) -> RestClient { + RestClient::new( + new_http_client(), + format!("http://{}/api", server.address()), + "client-id", + "user-agent", + "op-user-agent", + ) + .expect("valid headers") + } + + fn mfa(json: &str) -> MfaInfo { + serde_json::from_str(json).expect("valid mfa info") + } + + /// Encrypts `plaintext` for the session key so the mock can answer like the real server does. + fn encrypted_body(key: &AesKey, plaintext: &[u8]) -> Value { + let mut iv = [0u8; 12]; + bitwarden_random::rng().fill_bytes(&mut iv); + let envelope = key.encrypt(plaintext, &iv).expect("encrypts"); + serde_json::to_value(&envelope).expect("serializes") + } + + #[tokio::test] + async fn submits_the_code_from_the_callback() { + let key = session_key(); + let server = MockServer::start().await; + server + .register( + Mock::given(matchers::path("/api/v1/auth/mfa")) + .and(matchers::method("POST")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(encrypted_body(&key, br#"{"sessionID":"SESSION"}"#)), + ) + .expect(1), + ) + .await; + + let outcome = perform_second_factor_authentication( + &mfa(r#"{"totp":{"enabled":true}}"#), + &ClientInfo::for_desktop("device-uuid"), + &key, + 0, + &ScriptedUi::code("123456"), + &client(&server), + ) + .await + .expect("2FA completes"); + + assert!(matches!(outcome, MfaOutcome::Verified)); + server.verify().await; + } + + #[tokio::test] + async fn a_rejected_code_asks_for_a_restart() { + let key = session_key(); + let server = MockServer::start().await; + server + .register( + Mock::given(matchers::path("/api/v1/auth/mfa")) + .respond_with(ResponseTemplate::new(401).set_body_json( + serde_json::json!({"errorCode": 102, "errorMessage": "bad code"}), + )) + .expect(1), + ) + .await; + + let outcome = perform_second_factor_authentication( + &mfa(r#"{"totp":{"enabled":true}}"#), + &ClientInfo::for_desktop("device-uuid"), + &key, + 1, + &ScriptedUi::code("000000"), + &client(&server), + ) + .await + .expect("a bad code is not a hard failure"); + + assert!(matches!(outcome, MfaOutcome::BadOtp)); + server.verify().await; + } + + #[tokio::test] + async fn cancelling_the_prompt_fails_the_login() { + let server = MockServer::start().await; + + let error = perform_second_factor_authentication( + &mfa(r#"{"totp":{"enabled":true}}"#), + &ClientInfo::for_desktop("device-uuid"), + &session_key(), + 0, + &ScriptedUi::cancel(), + &client(&server), + ) + .await + .expect_err("the user declined"); + + assert!(matches!(error, OnePasswordError::TwoFactorFailed)); + } + + #[tokio::test] + async fn rejects_accounts_without_totp() { + let server = MockServer::start().await; + + let error = perform_second_factor_authentication( + &mfa(r#"{"totp":{"enabled":false},"duo":{"enabled":true}}"#), + &ClientInfo::for_desktop("device-uuid"), + &session_key(), + 0, + &ScriptedUi::code("123456"), + &client(&server), + ) + .await + .expect_err("Duo is not supported"); + + assert!(matches!(error, OnePasswordError::Unsupported(_))); + assert!(error.to_string().contains("Duo")); + } +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/access/wire.rs b/crates/bitwarden-importers/src/importers/onepassword/access/wire.rs new file mode 100644 index 000000000..a57498631 --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/access/wire.rs @@ -0,0 +1,387 @@ +//! serde DTOs for every 1Password endpoint. +//! +//! These carry only the fields the client reads. serde ignores everything else on the wire, so the +//! structs stay small while remaining forward compatible with the full server responses. + +use serde::{Deserialize, Serialize}; + +/// The JSON "opdata" envelope as it appears on the wire. +/// +/// It is also serialized back to the server as the request body of the encrypted POST endpoints. +#[derive(Debug, Deserialize, Serialize)] +pub(super) struct EncryptedEnvelope { + pub kid: String, + pub enc: String, + pub cty: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub iv: Option, + pub data: String, +} + +/// An RSA private key JWK as it appears on the wire. +/// +/// Extra JWK members (`dp`, `dq`, `qi`, `alg`, `kty`, `ext`) are ignored: the CRT values are +/// recomputed from `p` and `q` when the key is built. +#[derive(Debug, Deserialize)] +pub(super) struct RsaKeyJwk { + pub kid: String, + pub e: String, + pub n: String, + pub p: String, + pub q: String, + pub d: String, +} + +/// The decrypted AES key JSON. +#[derive(Deserialize)] +pub(super) struct AesKeyJson { + pub kid: String, + pub k: String, +} + +/// The `v1/account/keysets` payload. +#[derive(Debug, Deserialize)] +pub(super) struct KeysetsInfo { + pub keysets: Vec, +} + +/// A single keyset. +#[derive(Debug, Deserialize)] +pub(super) struct KeysetInfo { + pub uuid: String, + #[serde(default, rename = "encryptedBy")] + pub encrypted_by: String, + pub sn: i64, + #[serde(rename = "encSymKey")] + pub enc_sym_key: KeyDerivationInfo, + #[serde(rename = "encPriKey")] + pub enc_pri_key: EncryptedEnvelope, +} + +/// An encrypted symmetric key envelope that additionally carries the KDF parameters for the master +/// keyset (`alg`/`p2s`/`p2c`). +#[derive(Debug, Deserialize)] +pub(super) struct KeyDerivationInfo { + pub kid: String, + pub enc: String, + pub cty: String, + #[serde(default)] + pub iv: Option, + pub data: String, + #[serde(default)] + pub alg: Option, + #[serde(default)] + pub p2s: Option, + #[serde(default)] + pub p2c: u32, +} + +impl KeyDerivationInfo { + /// The envelope half, without the KDF parameters. + pub(super) fn envelope(&self) -> EncryptedEnvelope { + EncryptedEnvelope { + kid: self.kid.clone(), + enc: self.enc.clone(), + cty: self.cty.clone(), + iv: self.iv.clone(), + data: self.data.clone(), + } + } +} + +/// Response from `v2/auth/methods`. +#[derive(Debug, Deserialize)] +pub(super) struct LoginInfo { + #[serde(rename = "authMethods")] + pub auth_methods: Vec, +} + +/// A single auth method offered for an account. +#[derive(Debug, Deserialize)] +pub(super) struct AuthMethod { + #[serde(rename = "type")] + pub kind: String, +} + +/// Response from `v3/auth/start`. +/// +/// `status` drives the state machine: `ok` carries the SRP parameters, while +/// `device-not-registered` and `device-deleted` ask the client to (re)authorize the device and +/// retry. +#[derive(Debug, Deserialize)] +pub(super) struct NewSession { + pub status: String, + #[serde(rename = "sessionID")] + pub session_id: String, + #[serde(rename = "accountKeyFormat")] + pub key_format: Option, + #[serde(rename = "accountKeyUuid")] + pub key_uuid: Option, + #[serde(rename = "userAuth")] + pub auth: Option, +} + +/// The SRP parameters carried by a successful `NewSession`. +#[derive(Debug, Deserialize)] +pub(super) struct UserAuth { + pub method: String, + #[serde(rename = "alg")] + pub algorithm: String, + pub iterations: u32, + pub salt: String, +} + +/// Response from `v1/device` and `v1/device/{uuid}/reauthorize`. +#[derive(Debug, Deserialize)] +pub(super) struct SuccessStatus { + pub success: i32, +} + +/// Response from `v2/auth` (the SRP A -> B exchange). +#[derive(Debug, Deserialize)] +pub(super) struct AForB { + #[serde(rename = "userB")] + pub b: String, +} + +/// Response from `v2/auth/confirm-key`. +#[derive(Debug, Deserialize)] +pub(super) struct ServerHash { + #[serde(rename = "serverVerifyHash")] + pub server_verify_hash: String, +} + +/// Response from `v2/auth/complete` (decrypted). +#[derive(Debug, Deserialize)] +pub(super) struct AuthComplete { + pub mfa: Option, +} + +/// The set of 2FA methods enabled for an account. +/// +/// Only the enabled flags are modelled: TOTP is the one interactive method implemented, so the +/// per-method parameters (WebAuthn challenge, Duo host, and so on) are not read. +#[derive(Debug, Deserialize)] +pub(super) struct MfaInfo { + #[serde(rename = "totp")] + pub google_auth: Option, + #[serde(rename = "webAuthn")] + pub web_authn: Option, + pub duo: Option, + #[serde(rename = "dsecret")] + pub remember_me: Option, +} + +impl MfaInfo { + /// Whether TOTP (Google Authenticator) is enabled, the only interactive method supported. + pub(super) fn totp_enabled(&self) -> bool { + self.google_auth.as_ref().is_some_and(|f| f.enabled) + } + + /// Names of the enabled 2FA methods, in the order 1Password reports them. + pub(super) fn enabled_methods(&self) -> Vec<&'static str> { + let mut methods = Vec::new(); + for (factor, name) in [ + (&self.google_auth, "TOTP"), + (&self.web_authn, "WebAuthn"), + (&self.duo, "Duo"), + (&self.remember_me, "remember-me"), + ] { + if factor.as_ref().is_some_and(|f| f.enabled) { + methods.push(name); + } + } + methods + } +} + +/// The `{ "enabled": bool }` shared by every 2FA method entry. +#[derive(Debug, Deserialize)] +pub(super) struct BasicMfa { + pub enabled: bool, +} + +/// A server error body. +#[derive(Debug, Deserialize)] +pub(super) struct ErrorResponse { + #[serde(rename = "errorCode")] + pub code: i32, + #[serde(rename = "errorMessage")] + pub message: String, +} + +/// A server failure body used by some endpoints instead of `Error`. +#[derive(Debug, Deserialize)] +pub(super) struct FailureReason { + pub reason: String, +} + +/// Response from `v1/account` (decrypted). Only the vault list is used. +#[derive(Debug, Deserialize)] +pub(super) struct AccountInfo { + pub vaults: Vec, +} + +/// A vault entry in the account info. +#[derive(Debug, Deserialize)] +pub(super) struct VaultInfo { + pub uuid: String, + #[serde(rename = "encAttrs")] + pub enc_attrs: EncryptedEnvelope, + pub access: Vec, +} + +/// An access-control entry carrying the vault key encrypted for a key we may hold. +#[derive(Debug, Deserialize)] +pub(super) struct VaultAccess { + pub acl: i32, + #[serde(rename = "encVaultKey")] + pub enc_vault_key: EncryptedEnvelope, +} + +/// Decrypted vault attributes. +#[derive(Debug, Deserialize)] +pub(super) struct VaultAttributes { + pub name: Option, + pub desc: Option, +} + +/// A page of vault items. The last page is marked `batchComplete`. +#[derive(Debug, Deserialize)] +pub(super) struct VaultItemsBatch { + #[serde(rename = "contentVersion")] + pub version: i64, + #[serde(rename = "batchComplete")] + pub complete: bool, + pub items: Option>, +} + +/// A single encrypted vault item. +#[derive(Debug, Deserialize)] +pub(super) struct VaultItem { + pub uuid: String, + #[serde(rename = "templateUuid")] + pub template_uuid: String, + pub trashed: String, + #[serde(rename = "encOverview")] + pub enc_overview: EncryptedEnvelope, + #[serde(rename = "encDetails")] + pub enc_details: EncryptedEnvelope, +} + +/// A decrypted item overview. +#[derive(Debug, Deserialize)] +pub struct VaultItemOverview { + pub title: Option, + pub ainfo: Option, + pub url: Option, + #[serde(rename = "URLs")] + pub urls: Option>, + pub tags: Option>, +} + +/// A URL entry in an item overview. +#[derive(Debug, Deserialize)] +pub struct VaultItemUrl { + #[serde(rename = "l")] + pub name: Option, + #[serde(rename = "u")] + pub url: Option, +} + +/// Decrypted item details. +#[derive(Debug, Deserialize)] +pub struct VaultItemDetails { + #[serde(rename = "notesPlain")] + pub note: Option, + pub fields: Option>, + pub sections: Option>, + /// The secret of a Password-category item, which carries no `fields`. + pub password: Option, + #[serde(rename = "passwordHistory")] + pub password_history: Option>, +} + +/// A superseded password and the unix time it was replaced, oldest first. +#[derive(Debug, Deserialize)] +pub struct VaultItemPasswordHistory { + pub value: Option, + pub time: Option, +} + +/// A designation-based login field (username/password). +#[derive(Debug, Deserialize)] +pub struct VaultItemField { + pub designation: Option, + pub value: Option, + pub name: Option, + /// `T` for text, `P` for password. + #[serde(rename = "type")] + pub kind: Option, +} + +/// A titled section of fields. +#[derive(Debug, Deserialize)] +pub struct VaultItemSection { + /// The section's stable id, such as `Section_l2bagl3iupehvr7jvrc62mjhee`. + #[serde(rename = "name")] + pub id: Option, + #[serde(rename = "title")] + pub name: Option, + pub fields: Option>, +} + +/// A field inside a section. The value `v` can be any JSON type. +#[derive(Debug, Deserialize)] +pub struct VaultItemSectionField { + #[serde(rename = "n")] + pub id: Option, + #[serde(rename = "t")] + pub name: Option, + #[serde(rename = "v")] + pub value: Option, + #[serde(rename = "k")] + pub kind: Option, + #[serde(rename = "a")] + pub attributes: Option, + /// Keyboard hints for the 1Password UI, of no use to an import. + #[serde(rename = "inputTraits")] + pub input_traits: Option, +} + +/// How the 1Password UI should present a field's editor. +#[derive(Debug, Deserialize)] +pub struct VaultItemInputTraits { + pub autocapitalization: Option, + pub keyboard: Option, + pub correction: Option, +} + +/// Extra attributes on a section field. +#[derive(Debug, Deserialize)] +pub struct VaultItemFieldAttributes { + pub guarded: Option, + #[serde(rename = "sshKeyAttributes")] + pub ssh_key: Option, +} + +/// The SSH key material carried on a `sshKey` field. +#[derive(Debug, Deserialize)] +pub struct SshKeyAttributes { + #[serde(rename = "privateKey")] + pub private_key: Option, + #[serde(rename = "publicKey")] + pub public_key: Option, + pub fingerprint: Option, + #[serde(rename = "keyType")] + pub key_type: Option, +} + +/// An SSH key's type and, for RSA, its bit length. +#[derive(Debug, Deserialize)] +pub struct SshKeyType { + #[serde(rename = "t")] + pub kind: String, + #[serde(rename = "c", default)] + pub bits: i64, +} diff --git a/crates/bitwarden-importers/src/importers/onepassword/mod.rs b/crates/bitwarden-importers/src/importers/onepassword/mod.rs new file mode 100644 index 000000000..2b56283b3 --- /dev/null +++ b/crates/bitwarden-importers/src/importers/onepassword/mod.rs @@ -0,0 +1,8 @@ +//! 1Password importer. +//! +//! [`access`] is the Bitwarden-agnostic client that logs in and downloads the vaults. + +// `pub` for the `test-utils` re-export. Nothing in the SDK calls into it yet. +// TODO: Remove once the importer consumes the module directly. +#[allow(dead_code, unused_imports)] +pub mod access; diff --git a/crates/bitwarden-importers/src/lib.rs b/crates/bitwarden-importers/src/lib.rs index c8bc16aaf..b62297349 100644 --- a/crates/bitwarden-importers/src/lib.rs +++ b/crates/bitwarden-importers/src/lib.rs @@ -17,6 +17,14 @@ pub use importer_client::{ImporterClient, ImporterClientExt}; mod importers; mod pipeline; +/// The 1Password access module: log in to an account and download its vaults. +/// +/// Exposed only under the `test-utils` feature, for the out-of-tree CLI that drives it against +/// a real account. Not part of this crate's supported API, and no stability is promised. +// TODO: Remove once the importer consumes the module directly. +#[cfg(feature = "test-utils")] +pub use importers::onepassword::access as onepassword_access; + /// Destination options for a vault import. /// /// `organization_id` selects the destination: `None` imports into the user's personal vault (groups