diff --git a/Cargo.lock b/Cargo.lock index 587e9fc68..69fbbe5d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -352,7 +352,7 @@ dependencies = [ "serde", "serde_json", "serde_qs", - "thiserror 1.0.69", + "thiserror 2.0.12", "tsify", "uniffi", "wasm-bindgen", @@ -379,7 +379,7 @@ dependencies = [ "bitwarden-error", "serde", "serde_repr", - "thiserror 1.0.69", + "thiserror 2.0.12", "tsify", "uniffi", "uuid", @@ -395,6 +395,7 @@ dependencies = [ "bitwarden-api-api", "bitwarden-api-identity", "bitwarden-crypto", + "bitwarden-encoding", "bitwarden-error", "bitwarden-state", "bitwarden-uuid", @@ -430,6 +431,7 @@ dependencies = [ "aes", "argon2", "base64", + "bitwarden-encoding", "bitwarden-error", "cbc", "chacha20poly1305", @@ -465,6 +467,22 @@ dependencies = [ "zeroizing-alloc", ] +[[package]] +name = "bitwarden-encoding" +version = "1.0.0" +dependencies = [ + "data-encoding", + "data-encoding-macro", + "serde", + "serde-wasm-bindgen", + "serde_json", + "thiserror 2.0.12", + "tsify", + "uniffi", + "wasm-bindgen", + "wasm-bindgen-test", +] + [[package]] name = "bitwarden-error" version = "1.0.0" @@ -690,6 +708,7 @@ dependencies = [ "bitwarden-collections", "bitwarden-core", "bitwarden-crypto", + "bitwarden-encoding", "bitwarden-exporters", "bitwarden-fido", "bitwarden-generators", @@ -1412,6 +1431,26 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +[[package]] +name = "data-encoding-macro" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ce6c96ea0102f01122a185683611bd5ac8d99e62bc59dd12e6bda344ee673d" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" +dependencies = [ + "data-encoding", + "syn", +] + [[package]] name = "deadpool" version = "0.10.0" diff --git a/Cargo.toml b/Cargo.toml index 9475c153a..e7ad23864 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ bitwarden-cli = { path = "crates/bitwarden-cli", version = "=1.0.0" } bitwarden-collections = { path = "crates/bitwarden-collections", version = "=1.0.0" } bitwarden-core = { path = "crates/bitwarden-core", version = "=1.0.0" } bitwarden-crypto = { path = "crates/bitwarden-crypto", version = "=1.0.0" } +bitwarden-encoding = { path = "crates/bitwarden-encoding", version = "=1.0.0" } bitwarden-error = { path = "crates/bitwarden-error", version = "=1.0.0" } bitwarden-error-macro = { path = "crates/bitwarden-error-macro", version = "=1.0.0" } bitwarden-exporters = { path = "crates/bitwarden-exporters", version = "=1.0.0" } @@ -49,6 +50,7 @@ chrono = { version = ">=0.4.26, <0.5", features = [ "serde", "std", ], default-features = false } +data-encoding = ">=2.0, <3" js-sys = { version = ">=0.3.72, <0.4" } log = ">=0.4.18, <0.5" proc-macro2 = ">=1.0.89, <2" diff --git a/crates/bitwarden-core/Cargo.toml b/crates/bitwarden-core/Cargo.toml index 9f0915938..43fce88a7 100644 --- a/crates/bitwarden-core/Cargo.toml +++ b/crates/bitwarden-core/Cargo.toml @@ -34,6 +34,7 @@ base64 = ">=0.22.1, <0.23" bitwarden-api-api = { workspace = true } bitwarden-api-identity = { workspace = true } bitwarden-crypto = { workspace = true } +bitwarden-encoding = { workspace = true } bitwarden-error = { workspace = true } bitwarden-state = { workspace = true } bitwarden-uuid = { workspace = true } diff --git a/crates/bitwarden-core/src/auth/auth_client.rs b/crates/bitwarden-core/src/auth/auth_client.rs index c634225bd..7ed208bc8 100644 --- a/crates/bitwarden-core/src/auth/auth_client.rs +++ b/crates/bitwarden-core/src/auth/auth_client.rs @@ -2,6 +2,8 @@ use bitwarden_crypto::{ CryptoError, DeviceKey, EncString, Kdf, TrustDeviceResponse, UnsignedSharedKey, }; +#[cfg(feature = "internal")] +use bitwarden_encoding::B64; #[cfg(feature = "secrets")] use crate::auth::login::{login_access_token, AccessTokenLoginRequest, AccessTokenLoginResponse}; @@ -88,7 +90,7 @@ impl AuthClient { pub fn make_register_tde_keys( &self, email: String, - org_public_key: String, + org_public_key: B64, remember_device: bool, ) -> Result { make_register_tde_keys(&self.client, email, org_public_key, remember_device) diff --git a/crates/bitwarden-core/src/auth/tde.rs b/crates/bitwarden-core/src/auth/tde.rs index 20da1208e..e806a1069 100644 --- a/crates/bitwarden-core/src/auth/tde.rs +++ b/crates/bitwarden-core/src/auth/tde.rs @@ -1,8 +1,8 @@ -use base64::{engine::general_purpose::STANDARD, Engine}; use bitwarden_crypto::{ AsymmetricPublicCryptoKey, DeviceKey, EncString, Kdf, SpkiPublicKeyBytes, SymmetricCryptoKey, TrustDeviceResponse, UnsignedSharedKey, UserKey, }; +use bitwarden_encoding::B64; use crate::{ client::{encryption_settings::EncryptionSettingsError, internal::UserKeyState}, @@ -15,12 +15,11 @@ use crate::{ pub(super) fn make_register_tde_keys( client: &Client, email: String, - org_public_key: String, + org_public_key: B64, remember_device: bool, ) -> Result { - let public_key = AsymmetricPublicCryptoKey::from_der(&SpkiPublicKeyBytes::from( - STANDARD.decode(org_public_key)?, - ))?; + let public_key = + AsymmetricPublicCryptoKey::from_der(&SpkiPublicKeyBytes::from(org_public_key.as_ref()))?; let user_key = UserKey::new(SymmetricCryptoKey::make_aes256_cbc_hmac_key()); let key_pair = user_key.make_key_pair()?; diff --git a/crates/bitwarden-core/src/key_management/security_state.rs b/crates/bitwarden-core/src/key_management/security_state.rs index 9a423ebb5..385b41d30 100644 --- a/crates/bitwarden-core/src/key_management/security_state.rs +++ b/crates/bitwarden-core/src/key_management/security_state.rs @@ -24,9 +24,10 @@ use std::str::FromStr; use base64::{engine::general_purpose::STANDARD, Engine}; use bitwarden_crypto::{ - CoseSerializable, CoseSign1Bytes, CryptoError, EncodingError, FromStrVisitor, KeyIds, - KeyStoreContext, SignedObject, SigningNamespace, VerifyingKey, + CoseSerializable, CoseSign1Bytes, CryptoError, EncodingError, KeyIds, KeyStoreContext, + SignedObject, SigningNamespace, VerifyingKey, }; +use bitwarden_encoding::FromStrVisitor; use serde::{Deserialize, Serialize}; use uuid::Uuid; diff --git a/crates/bitwarden-crypto/Cargo.toml b/crates/bitwarden-crypto/Cargo.toml index db8633b29..1e53ef50f 100644 --- a/crates/bitwarden-crypto/Cargo.toml +++ b/crates/bitwarden-crypto/Cargo.toml @@ -28,6 +28,7 @@ argon2 = { version = ">=0.5.0, <0.6", features = [ "zeroize", ], default-features = false } base64 = ">=0.22.1, <0.23" +bitwarden-encoding = { workspace = true } bitwarden-error = { workspace = true } cbc = { version = ">=0.1.2, <0.2", features = ["alloc", "zeroize"] } chacha20poly1305 = { version = "0.10.1" } @@ -46,7 +47,7 @@ rayon = ">=1.8.1, <2.0" rsa = ">=0.9.2, <0.10" schemars = { workspace = true } serde = { workspace = true } -serde_bytes = { workspace = true } +serde_bytes = { workspace = true } serde_repr.workspace = true sha1 = ">=0.10.5, <0.11" sha2 = ">=0.10.6, <0.11" diff --git a/crates/bitwarden-crypto/src/enc_string/asymmetric.rs b/crates/bitwarden-crypto/src/enc_string/asymmetric.rs index cfad2fb33..cf6c7ccc8 100644 --- a/crates/bitwarden-crypto/src/enc_string/asymmetric.rs +++ b/crates/bitwarden-crypto/src/enc_string/asymmetric.rs @@ -1,6 +1,7 @@ use std::{borrow::Cow, fmt::Display, str::FromStr}; use base64::{engine::general_purpose::STANDARD, Engine}; +use bitwarden_encoding::FromStrVisitor; pub use internal::UnsignedSharedKey; use rsa::Oaep; use serde::Deserialize; @@ -9,7 +10,6 @@ use super::{from_b64_vec, split_enc_string}; use crate::{ error::{CryptoError, EncStringParseError, Result}, rsa::encrypt_rsa2048_oaep_sha1, - util::FromStrVisitor, AsymmetricCryptoKey, AsymmetricPublicCryptoKey, BitwardenLegacyKeyBytes, RawPrivateKey, RawPublicKey, SymmetricCryptoKey, }; diff --git a/crates/bitwarden-crypto/src/enc_string/symmetric.rs b/crates/bitwarden-crypto/src/enc_string/symmetric.rs index e7647916b..0b8be641d 100644 --- a/crates/bitwarden-crypto/src/enc_string/symmetric.rs +++ b/crates/bitwarden-crypto/src/enc_string/symmetric.rs @@ -1,13 +1,13 @@ use std::{borrow::Cow, str::FromStr}; use base64::{engine::general_purpose::STANDARD, Engine}; +use bitwarden_encoding::FromStrVisitor; use coset::CborSerializable; use serde::Deserialize; use super::{check_length, from_b64, from_b64_vec, split_enc_string}; use crate::{ error::{CryptoError, EncStringParseError, Result, UnsupportedOperation}, - util::FromStrVisitor, Aes256CbcHmacKey, ContentFormat, KeyDecryptable, KeyEncryptable, KeyEncryptableWithContentType, SymmetricCryptoKey, Utf8Bytes, XChaCha20Poly1305Key, }; diff --git a/crates/bitwarden-crypto/src/keys/signed_public_key.rs b/crates/bitwarden-crypto/src/keys/signed_public_key.rs index bc54b7285..d0a3dd751 100644 --- a/crates/bitwarden-crypto/src/keys/signed_public_key.rs +++ b/crates/bitwarden-crypto/src/keys/signed_public_key.rs @@ -5,15 +5,16 @@ use std::{borrow::Cow, str::FromStr}; use base64::{engine::general_purpose::STANDARD, Engine}; +use bitwarden_encoding::FromStrVisitor; use serde::{Deserialize, Serialize}; use serde_bytes::ByteBuf; use serde_repr::{Deserialize_repr, Serialize_repr}; use super::AsymmetricPublicCryptoKey; use crate::{ - cose::CoseSerializable, error::EncodingError, util::FromStrVisitor, CoseSign1Bytes, - CryptoError, PublicKeyEncryptionAlgorithm, RawPublicKey, SignedObject, SigningKey, - SigningNamespace, SpkiPublicKeyBytes, VerifyingKey, + cose::CoseSerializable, error::EncodingError, CoseSign1Bytes, CryptoError, + PublicKeyEncryptionAlgorithm, RawPublicKey, SignedObject, SigningKey, SigningNamespace, + SpkiPublicKeyBytes, VerifyingKey, }; #[cfg(feature = "wasm")] diff --git a/crates/bitwarden-crypto/src/lib.rs b/crates/bitwarden-crypto/src/lib.rs index 0c714fb23..88ade0d76 100644 --- a/crates/bitwarden-crypto/src/lib.rs +++ b/crates/bitwarden-crypto/src/lib.rs @@ -27,7 +27,7 @@ pub use keys::*; mod rsa; pub use crate::rsa::RsaKeyPair; mod util; -pub use util::{generate_random_alphanumeric, generate_random_bytes, pbkdf2, FromStrVisitor}; +pub use util::{generate_random_alphanumeric, generate_random_bytes, pbkdf2}; mod wordlist; pub use wordlist::EFF_LONG_WORD_LIST; mod store; diff --git a/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs b/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs index 6b28ffec6..a527b3eb7 100644 --- a/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs +++ b/crates/bitwarden-crypto/src/safe/password_protected_key_envelope.rs @@ -17,6 +17,7 @@ use std::{marker::PhantomData, num::TryFromIntError, str::FromStr}; use argon2::Params; use base64::{engine::general_purpose::STANDARD, Engine}; +use bitwarden_encoding::FromStrVisitor; use ciborium::{value::Integer, Value}; use coset::{CborSerializable, CoseError, Header, HeaderBuilder}; use rand::RngCore; @@ -28,8 +29,8 @@ use crate::{ extract_bytes, extract_integer, CoseExtractError, ALG_ARGON2ID13, ARGON2_ITERATIONS, ARGON2_MEMORY, ARGON2_PARALLELISM, ARGON2_SALT, }, - xchacha20, BitwardenLegacyKeyBytes, ContentFormat, CoseKeyBytes, EncodedSymmetricKey, - FromStrVisitor, KeyIds, KeyStoreContext, SymmetricCryptoKey, + xchacha20, BitwardenLegacyKeyBytes, ContentFormat, CoseKeyBytes, EncodedSymmetricKey, KeyIds, + KeyStoreContext, SymmetricCryptoKey, }; /// 16 is the RECOMMENDED salt size for all applications: diff --git a/crates/bitwarden-crypto/src/util.rs b/crates/bitwarden-crypto/src/util.rs index a556f9434..48a899a8e 100644 --- a/crates/bitwarden-crypto/src/util.rs +++ b/crates/bitwarden-crypto/src/util.rs @@ -1,4 +1,4 @@ -use std::{pin::Pin, str::FromStr}; +use std::pin::Pin; use ::aes::cipher::{ArrayLength, Unsigned}; use generic_array::GenericArray; @@ -53,37 +53,6 @@ pub fn pbkdf2(password: &[u8], salt: &[u8], rounds: u32) -> [u8; PBKDF_SHA256_HM .expect("hash is a valid fixed size") } -/// A serde visitor that converts a string to a type that implements `FromStr`. -pub struct FromStrVisitor(std::marker::PhantomData); -impl FromStrVisitor { - /// Create a new `FromStrVisitor` for the given type. - pub fn new() -> Self { - Self::default() - } -} -impl Default for FromStrVisitor { - fn default() -> Self { - Self(Default::default()) - } -} -impl serde::de::Visitor<'_> for FromStrVisitor -where - T::Err: std::fmt::Debug, -{ - type Value = T; - - fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "a valid string") - } - - fn visit_str(self, v: &str) -> Result - where - E: serde::de::Error, - { - T::from_str(v).map_err(|e| E::custom(format!("{e:?}"))) - } -} - #[cfg(test)] mod tests { use typenum::U64; diff --git a/crates/bitwarden-encoding/Cargo.toml b/crates/bitwarden-encoding/Cargo.toml new file mode 100644 index 000000000..d0eaff18b --- /dev/null +++ b/crates/bitwarden-encoding/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "bitwarden-encoding" +description = """ +Internal crate for the bitwarden crate. Do not use. +""" + +version.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +readme.workspace = true +homepage.workspace = true +repository.workspace = true +license-file.workspace = true +keywords.workspace = true + +[features] +uniffi = ["dep:uniffi"] +wasm = ["dep:tsify", "dep:wasm-bindgen"] + +[dependencies] +data-encoding = { workspace = true } +data-encoding-macro = "0.1.18" +serde = { workspace = true } +thiserror.workspace = true +tsify = { workspace = true, optional = true } +uniffi = { workspace = true, optional = true } +wasm-bindgen = { workspace = true, optional = true } + +[dev-dependencies] +serde-wasm-bindgen = { workspace = true } +serde_json = { workspace = true } +wasm-bindgen-test = { workspace = true } + +[lints] +workspace = true diff --git a/crates/bitwarden-encoding/README.md b/crates/bitwarden-encoding/README.md new file mode 100644 index 000000000..1fd339a97 --- /dev/null +++ b/crates/bitwarden-encoding/README.md @@ -0,0 +1,3 @@ +# Bitwarden Encoding + +Provides Base64 and Base64Url encoding and decoding utilities for working with Bitwarden data. diff --git a/crates/bitwarden-encoding/src/b64.rs b/crates/bitwarden-encoding/src/b64.rs new file mode 100644 index 000000000..0fd9915e3 --- /dev/null +++ b/crates/bitwarden-encoding/src/b64.rs @@ -0,0 +1,262 @@ +use std::str::FromStr; + +use data_encoding::BASE64; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +#[cfg(feature = "wasm")] +use tsify::Tsify; + +use crate::FromStrVisitor; + +/// Base64 encoded data +/// +/// Is indifferent about padding when decoding, but always produces padding when encoding. +#[cfg(feature = "wasm")] +#[derive(Debug, Serialize, Clone, Hash, PartialEq, Eq)] +#[serde(into = "String")] +#[derive(Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub struct B64(#[tsify(type = "String")] Vec); + +/// Base64 encoded data +/// +/// Is indifferent about padding when decoding, but always produces padding when encoding. +#[cfg(not(feature = "wasm"))] +#[derive(Debug, Serialize, Clone, Hash, PartialEq, Eq)] +#[serde(into = "String")] +pub struct B64(Vec); + +// We manually implement this to handle both `String` and `&str` +impl<'de> Deserialize<'de> for B64 { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_str(FromStrVisitor::new()) + } +} + +impl From> for B64 { + fn from(src: Vec) -> Self { + Self(src) + } +} +impl From<&[u8]> for B64 { + fn from(src: &[u8]) -> Self { + Self(src.to_vec()) + } +} + +impl From for Vec { + fn from(src: B64) -> Self { + src.0 + } +} + +impl AsRef<[u8]> for B64 { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl From for String { + fn from(src: B64) -> Self { + String::from(&src) + } +} + +impl From<&B64> for String { + fn from(src: &B64) -> Self { + BASE64.encode(&src.0) + } +} + +impl std::fmt::Display for B64 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(String::from(self).as_str()) + } +} + +/// An error returned when a string is not base64 decodable. +#[derive(Debug, Error)] +#[error("Data isn't base64 encoded")] +pub struct NotB64Encoded; + +const BASE64_PERMISSIVE: data_encoding::Encoding = data_encoding_macro::new_encoding! { + symbols: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", + padding: None, + check_trailing_bits: false, +}; +const BASE64_PADDING: &str = "="; + +impl TryFrom for B64 { + type Error = NotB64Encoded; + + fn try_from(value: String) -> Result { + let sane_string = value.trim_end_matches(BASE64_PADDING); + BASE64_PERMISSIVE + .decode(sane_string.as_bytes()) + .map(Self) + .map_err(|_| NotB64Encoded) + } +} + +impl TryFrom<&str> for B64 { + type Error = NotB64Encoded; + + fn try_from(value: &str) -> Result { + let sane_string = value.trim_end_matches(BASE64_PADDING); + BASE64_PERMISSIVE + .decode(sane_string.as_bytes()) + .map(Self) + .map_err(|_| NotB64Encoded) + } +} + +impl FromStr for B64 { + type Err = NotB64Encoded; + + fn from_str(s: &str) -> Result { + Self::try_from(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_b64_from_vec() { + let data = vec![72, 101, 108, 108, 111]; + let b64 = B64::from(data.clone()); + assert_eq!(Vec::::from(b64), data); + } + + #[test] + fn test_b64_from_slice() { + let data = b"Hello"; + let b64 = B64::from(data.as_slice()); + assert_eq!(b64.as_ref(), data); + } + + #[test] + fn test_b64_encoding_with_padding() { + let data = b"Hello, World!"; + let b64 = B64::from(data.as_slice()); + let encoded = String::from(&b64); + assert_eq!(encoded, "SGVsbG8sIFdvcmxkIQ=="); + assert!(encoded.contains('=')); + } + + #[test] + fn test_b64_decoding_with_padding() { + let encoded_with_padding = "SGVsbG8sIFdvcmxkIQ=="; + let b64 = B64::try_from(encoded_with_padding).unwrap(); + assert_eq!(b64.as_ref(), b"Hello, World!"); + } + + #[test] + fn test_b64_decoding_without_padding() { + let encoded_without_padding = "SGVsbG8sIFdvcmxkIQ"; + let b64 = B64::try_from(encoded_without_padding).unwrap(); + assert_eq!(b64.as_ref(), b"Hello, World!"); + } + + #[test] + fn test_b64_round_trip_with_padding() { + let original = b"Test data that requires padding!"; + let b64 = B64::from(original.as_slice()); + let encoded = String::from(&b64); + let decoded = B64::try_from(encoded.as_str()).unwrap(); + assert_eq!(decoded.as_ref(), original); + } + + #[test] + fn test_b64_round_trip_without_padding() { + let original = b"Test data"; + let b64 = B64::from(original.as_slice()); + let encoded = String::from(&b64); + let decoded = B64::try_from(encoded.as_str()).unwrap(); + assert_eq!(decoded.as_ref(), original); + } + + #[test] + fn test_b64_display() { + let data = b"Hello"; + let b64 = B64::from(data.as_slice()); + assert_eq!(b64.to_string(), "SGVsbG8="); + } + + #[test] + fn test_b64_invalid_encoding() { + let invalid_b64 = "This is not base64!@#$"; + let result = B64::try_from(invalid_b64); + assert!(result.is_err()); + } + + #[test] + fn test_b64_empty_string() { + let empty = ""; + let b64 = B64::try_from(empty).unwrap(); + assert_eq!(b64.as_ref().len(), 0); + } + + #[test] + fn test_b64_padding_removal() { + let encoded_with_padding = "SGVsbG8sIFdvcmxkIQ=="; + let b64 = B64::try_from(encoded_with_padding).unwrap(); + assert_eq!(b64.as_ref(), b"Hello, World!"); + } + + #[test] + fn test_b64_serialization() { + let data = b"serialization test"; + let b64 = B64::from(data.as_slice()); + + let serialized = serde_json::to_string(&b64).unwrap(); + assert_eq!(serialized, "\"c2VyaWFsaXphdGlvbiB0ZXN0\""); + + let deserialized: B64 = serde_json::from_str(&serialized).unwrap(); + assert_eq!(b64.as_ref(), deserialized.as_ref()); + } + + #[test] + fn test_not_b64_encoded_error_display() { + let error = NotB64Encoded; + assert_eq!(error.to_string(), "Data isn't base64 encoded"); + } + + #[test] + fn test_b64_from_str() { + let encoded = "SGVsbG8sIFdvcmxkIQ=="; + let b64: B64 = encoded.parse().unwrap(); + assert_eq!(b64.as_ref(), b"Hello, World!"); + } + + #[test] + fn test_b64_eq_and_hash() { + let data1 = b"test data"; + let data2 = b"test data"; + let data3 = b"different data"; + + let b64_1 = B64::from(data1.as_slice()); + let b64_2 = B64::from(data2.as_slice()); + let b64_3 = B64::from(data3.as_slice()); + + assert_eq!(b64_1, b64_2); + assert_ne!(b64_1, b64_3); + + use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + }; + + let mut hasher1 = DefaultHasher::new(); + let mut hasher2 = DefaultHasher::new(); + + b64_1.hash(&mut hasher1); + b64_2.hash(&mut hasher2); + + assert_eq!(hasher1.finish(), hasher2.finish()); + } +} diff --git a/crates/bitwarden-encoding/src/b64url.rs b/crates/bitwarden-encoding/src/b64url.rs new file mode 100644 index 000000000..2ce668263 --- /dev/null +++ b/crates/bitwarden-encoding/src/b64url.rs @@ -0,0 +1,225 @@ +use std::str::FromStr; + +use data_encoding::BASE64URL; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Base64URL encoded data +/// +/// Is indifferent about padding when decoding, but always produces padding when encoding. +#[derive(Debug, Serialize, Deserialize, Clone, Hash, PartialEq, Eq)] +#[serde(try_from = "&str", into = "String")] +pub struct B64Url(Vec); + +impl From> for B64Url { + fn from(src: Vec) -> Self { + Self(src) + } +} +impl From<&[u8]> for B64Url { + fn from(src: &[u8]) -> Self { + Self(src.to_vec()) + } +} + +impl From for Vec { + fn from(src: B64Url) -> Self { + src.0 + } +} + +impl AsRef<[u8]> for B64Url { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl From for String { + fn from(src: B64Url) -> Self { + String::from(&src) + } +} + +impl From<&B64Url> for String { + fn from(src: &B64Url) -> Self { + BASE64URL.encode(&src.0) + } +} + +impl std::fmt::Display for B64Url { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(String::from(self).as_str()) + } +} + +/// An error returned when a string is not base64 decodable. +#[derive(Debug, Error)] +#[error("Data isn't base64url encoded")] +pub struct NotB64UrlEncoded; + +const BASE64URL_PERMISSIVE: data_encoding::Encoding = data_encoding_macro::new_encoding! { + symbols: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_", + padding: None, + check_trailing_bits: false, +}; +const BASE64URL_PADDING: &str = "="; + +impl TryFrom<&str> for B64Url { + type Error = NotB64UrlEncoded; + + fn try_from(value: &str) -> Result { + let sane_string = value.trim_end_matches(BASE64URL_PADDING); + BASE64URL_PERMISSIVE + .decode(sane_string.as_bytes()) + .map(Self) + .map_err(|_| NotB64UrlEncoded) + } +} + +impl FromStr for B64Url { + type Err = NotB64UrlEncoded; + + fn from_str(s: &str) -> Result { + Self::try_from(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_b64url_from_vec() { + let data = vec![72, 101, 108, 108, 111]; + let b64url = B64Url::from(data.clone()); + assert_eq!(Vec::::from(b64url), data); + } + + #[test] + fn test_b64url_from_slice() { + let data = b"Hello"; + let b64url = B64Url::from(data.as_slice()); + assert_eq!(b64url.as_ref(), data); + } + + #[test] + fn test_b64url_encoding_with_padding() { + let data = b"Hello, World!"; + let b64url = B64Url::from(data.as_slice()); + let encoded = String::from(&b64url); + assert_eq!(encoded, "SGVsbG8sIFdvcmxkIQ=="); + assert!(encoded.contains('=')); + } + + #[test] + fn test_b64url_decoding_with_padding() { + let encoded_with_padding = "SGVsbG8sIFdvcmxkIQ=="; + let b64url = B64Url::try_from(encoded_with_padding).unwrap(); + assert_eq!(b64url.as_ref(), b"Hello, World!"); + } + + #[test] + fn test_b64url_decoding_without_padding() { + let encoded_without_padding = "SGVsbG8sIFdvcmxkIQ"; + let b64url = B64Url::try_from(encoded_without_padding).unwrap(); + assert_eq!(b64url.as_ref(), b"Hello, World!"); + } + + #[test] + fn test_b64url_round_trip_with_padding() { + let original = b"Test data that requires padding!"; + let b64url = B64Url::from(original.as_slice()); + let encoded = String::from(&b64url); + let decoded = B64Url::try_from(encoded.as_str()).unwrap(); + assert_eq!(decoded.as_ref(), original); + } + + #[test] + fn test_b64url_round_trip_without_padding() { + let original = b"Test data"; + let b64url = B64Url::from(original.as_slice()); + let encoded = String::from(&b64url); + let decoded = B64Url::try_from(encoded.as_str()).unwrap(); + assert_eq!(decoded.as_ref(), original); + } + + #[test] + fn test_b64url_display() { + let data = b"Hello"; + let b64url = B64Url::from(data.as_slice()); + assert_eq!(b64url.to_string(), "SGVsbG8="); + } + + #[test] + fn test_b64url_invalid_encoding() { + let invalid_b64url = "This is not base64url!@#$"; + let result = B64Url::try_from(invalid_b64url); + assert!(result.is_err()); + } + + #[test] + fn test_b64url_empty_string() { + let empty = ""; + let b64url = B64Url::try_from(empty).unwrap(); + assert_eq!(b64url.as_ref().len(), 0); + } + + #[test] + fn test_b64url_padding_removal() { + let encoded_with_padding = "SGVsbG8sIFdvcmxkIQ=="; + let b64url = B64Url::try_from(encoded_with_padding).unwrap(); + assert_eq!(b64url.as_ref(), b"Hello, World!"); + } + + #[test] + fn test_b64url_serialization() { + let data = b"serialization test"; + let b64url = B64Url::from(data.as_slice()); + + let serialized = serde_json::to_string(&b64url).unwrap(); + assert_eq!(serialized, "\"c2VyaWFsaXphdGlvbiB0ZXN0\""); + + let deserialized: B64Url = serde_json::from_str(&serialized).unwrap(); + assert_eq!(b64url.as_ref(), deserialized.as_ref()); + } + + #[test] + fn test_not_b64url_encoded_error_display() { + let error = NotB64UrlEncoded; + assert_eq!(error.to_string(), "Data isn't base64url encoded"); + } + + #[test] + fn test_b64url_from_str() { + let encoded = "SGVsbG8sIFdvcmxkIQ=="; + let b64url: B64Url = encoded.parse().unwrap(); + assert_eq!(b64url.as_ref(), b"Hello, World!"); + } + + #[test] + fn test_b64url_eq_and_hash() { + let data1 = b"test data"; + let data2 = b"test data"; + let data3 = b"different data"; + + let b64url_1 = B64Url::from(data1.as_slice()); + let b64url_2 = B64Url::from(data2.as_slice()); + let b64url_3 = B64Url::from(data3.as_slice()); + + assert_eq!(b64url_1, b64url_2); + assert_ne!(b64url_1, b64url_3); + + use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + }; + + let mut hasher1 = DefaultHasher::new(); + let mut hasher2 = DefaultHasher::new(); + + b64url_1.hash(&mut hasher1); + b64url_2.hash(&mut hasher2); + + assert_eq!(hasher1.finish(), hasher2.finish()); + } +} diff --git a/crates/bitwarden-encoding/src/lib.rs b/crates/bitwarden-encoding/src/lib.rs new file mode 100644 index 000000000..1013a9fe3 --- /dev/null +++ b/crates/bitwarden-encoding/src/lib.rs @@ -0,0 +1,15 @@ +#![doc = include_str!("../README.md")] + +mod b64; +mod b64url; +mod serde; + +pub use b64::B64; +pub use b64url::B64Url; +pub use serde::FromStrVisitor; + +#[cfg(feature = "uniffi")] +uniffi::setup_scaffolding!(); + +#[cfg(feature = "uniffi")] +mod uniffi_support; diff --git a/crates/bitwarden-encoding/src/serde.rs b/crates/bitwarden-encoding/src/serde.rs new file mode 100644 index 000000000..f82f56606 --- /dev/null +++ b/crates/bitwarden-encoding/src/serde.rs @@ -0,0 +1,32 @@ +use std::str::FromStr; + +/// A serde visitor that converts a string to a type that implements `FromStr`. +pub struct FromStrVisitor(std::marker::PhantomData); +impl FromStrVisitor { + /// Create a new `FromStrVisitor` for the given type. + pub fn new() -> Self { + Self::default() + } +} +impl Default for FromStrVisitor { + fn default() -> Self { + Self(Default::default()) + } +} +impl serde::de::Visitor<'_> for FromStrVisitor +where + T::Err: std::fmt::Debug, +{ + type Value = T; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "a valid string") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + T::from_str(v).map_err(|e| E::custom(format!("{e:?}"))) + } +} diff --git a/crates/bitwarden-encoding/src/uniffi_support.rs b/crates/bitwarden-encoding/src/uniffi_support.rs new file mode 100644 index 000000000..171b8aca5 --- /dev/null +++ b/crates/bitwarden-encoding/src/uniffi_support.rs @@ -0,0 +1,15 @@ +use crate::{b64::NotB64Encoded, b64url::NotB64UrlEncoded, B64Url, B64}; + +uniffi::custom_type!(B64, String, { + try_lift: |val| { + val.parse().map_err(|e: NotB64Encoded| e.into()) + }, + lower: |obj| obj.to_string(), +}); + +uniffi::custom_type!(B64Url, String, { + try_lift: |val| { + val.parse().map_err(|e: NotB64UrlEncoded| e.into()) + }, + lower: |obj| obj.to_string(), +}); diff --git a/crates/bitwarden-encoding/tests/wasm_b64.rs b/crates/bitwarden-encoding/tests/wasm_b64.rs new file mode 100644 index 000000000..85c26dbae --- /dev/null +++ b/crates/bitwarden-encoding/tests/wasm_b64.rs @@ -0,0 +1,46 @@ +//! Test for Base64 encoding and decoding in WASM +#![cfg(all(target_arch = "wasm32", feature = "wasm"))] + +use bitwarden_encoding::B64; +use wasm_bindgen_test::*; + +#[cfg(feature = "wasm")] +#[wasm_bindgen_test] +fn test_b64_wasm_serde_serialize() { + let data = b"WASM serialization test"; + let b64 = B64::from(data.as_slice()); + + let js_value = serde_wasm_bindgen::to_value(&b64).unwrap(); + + // The B64 should serialize as a base64 string + let expected_b64_string = "V0FTTSBzZXJpYWxpemF0aW9uIHRlc3Q="; + assert_eq!(js_value.as_string().unwrap(), expected_b64_string); +} + +#[cfg(feature = "wasm")] +#[wasm_bindgen_test] +fn test_b64_wasm_serde_deserialize() { + use wasm_bindgen::JsValue; + + let base64_string = "V0FTTSBzZXJpYWxpemF0aW9uIHRlc3Q="; + let js_value = JsValue::from_str(base64_string); + + let b64: B64 = serde_wasm_bindgen::from_value(js_value).unwrap(); + assert_eq!(b64.as_ref(), b"WASM serialization test"); +} + +#[cfg(feature = "wasm")] +#[wasm_bindgen_test] +fn test_b64_wasm_serde_round_trip() { + let original_data = "Round trip WASM test with Unicode: 🦀🚀".as_bytes(); + let original_b64 = B64::from(original_data); + + // Serialize to JS value + let js_value = serde_wasm_bindgen::to_value(&original_b64).unwrap(); + + // Deserialize back to B64 + let deserialized_b64: B64 = serde_wasm_bindgen::from_value(js_value).unwrap(); + + assert_eq!(original_b64.as_ref(), deserialized_b64.as_ref()); + assert_eq!(deserialized_b64.as_ref(), original_data); +} diff --git a/crates/bitwarden-encoding/uniffi.toml b/crates/bitwarden-encoding/uniffi.toml new file mode 100644 index 000000000..9fe9c8758 --- /dev/null +++ b/crates/bitwarden-encoding/uniffi.toml @@ -0,0 +1,9 @@ +[bindings.kotlin] +package_name = "com.bitwarden.encoding" +generate_immutable_records = true +android = true + +[bindings.swift] +ffi_module_name = "BitwardenEncodingFFI" +module_name = "BitwardenEncoding" +generate_immutable_records = true diff --git a/crates/bitwarden-uniffi/Cargo.toml b/crates/bitwarden-uniffi/Cargo.toml index 1716ccbcc..f8f7bdce6 100644 --- a/crates/bitwarden-uniffi/Cargo.toml +++ b/crates/bitwarden-uniffi/Cargo.toml @@ -23,6 +23,7 @@ async-trait = { workspace = true } bitwarden-collections = { workspace = true, features = ["uniffi"] } bitwarden-core = { workspace = true, features = ["uniffi"] } bitwarden-crypto = { workspace = true, features = ["uniffi"] } +bitwarden-encoding = { workspace = true, features = ["uniffi"] } bitwarden-exporters = { workspace = true, features = ["uniffi"] } bitwarden-fido = { workspace = true, features = ["uniffi"] } bitwarden-generators = { workspace = true, features = ["uniffi"] } diff --git a/crates/bitwarden-uniffi/src/auth/mod.rs b/crates/bitwarden-uniffi/src/auth/mod.rs index 5f90b1673..7fc8de448 100644 --- a/crates/bitwarden-uniffi/src/auth/mod.rs +++ b/crates/bitwarden-uniffi/src/auth/mod.rs @@ -3,6 +3,7 @@ use bitwarden_core::auth::{ RegisterKeyResponse, RegisterTdeKeyResponse, }; use bitwarden_crypto::{EncString, HashPurpose, Kdf, TrustDeviceResponse, UnsignedSharedKey}; +use bitwarden_encoding::B64; use crate::error::{Error, Result}; @@ -67,7 +68,7 @@ impl AuthClient { pub fn make_register_tde_keys( &self, email: String, - org_public_key: String, + org_public_key: B64, remember_device: bool, ) -> Result { Ok(self diff --git a/crates/bitwarden-vault/Cargo.toml b/crates/bitwarden-vault/Cargo.toml index 6f1cf2828..af380e5bf 100644 --- a/crates/bitwarden-vault/Cargo.toml +++ b/crates/bitwarden-vault/Cargo.toml @@ -37,7 +37,7 @@ bitwarden-crypto = { workspace = true } bitwarden-error = { workspace = true } bitwarden-state = { workspace = true } chrono = { workspace = true } -data-encoding = ">=2.0, <3" +data-encoding = { workspace = true } hmac = ">=0.12.1, <0.13" percent-encoding = ">=2.1, <3.0" reqwest = { workspace = true }