mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-11-13 23:46:04 +02:00
Run the Bitwarden SDK's live end-to-end tests against Vaultwarden (#7953)
* Add a test seeder behind the unsafe_test_mode feature For the end-to-end tests: with the feature, /test/seeder answers the calls the SDK's live tests make to a seeder API (POST /seed with SingleUserScene or TestVectorUserScene, DELETE /seed/<playId>), behind HTTP basic auth from TEST_SEEDER_USERNAME and TEST_SEEDER_PASSWORD, and 404 without them. It creates accounts without any checks, so no release build has the feature. CI builds and tests it with sqlite. * Run the SDK live tests against the test seeder tools/sdk-live-tests/run.sh builds the server with unsafe_test_mode and runs the live tests of the SDK's e2e/wasm suite. For SDK versions whose tests log in to one given account, each user test vector with a master password is seeded with its vault on a fresh server; for versions that seed their own accounts, the tests get the seeder's address. Every vector runs, then a summary. The workflow runs it by hand, and on changes to the seeder or the runner. * Run the SDK live tests daily * Test the published SDK package by default in the SDK live tests
This commit is contained in:
1 parent
730048e3c6
commit
d2d1294098
12 files changed
+997
-1
No files matched your search
@@ -0,0 +1,511 @@
|
||||
//! A seeder API for the end-to-end tests, only in builds with the `unsafe_test_mode` feature: it creates accounts with
|
||||
//! no checks at all, so it must never be in a release or a deployment.
|
||||
//!
|
||||
//! It answers the calls the SDK's live tests make to upstream's `util/SeederApi` (`POST /seed` with a scene,
|
||||
//! `DELETE /seed/{playId}`), under `/test/seeder`. Each request needs HTTP basic auth with `TEST_SEEDER_USERNAME` and
|
||||
//! `TEST_SEEDER_PASSWORD` from the environment; without both, every route answers 404. Seeding needs an `X-Play-Id`
|
||||
//! header, and destroying a play deletes the accounts seeded under it. Plays are kept in memory, which is enough for a
|
||||
//! test server that starts on an empty database.
|
||||
//!
|
||||
//! Scenes:
|
||||
//! - `SingleUserScene`: a V1 account for an email and password, under a mangled address like upstream's, with
|
||||
//! PBKDF2 at upstream's default of 5000 iterations.
|
||||
//! - `TestVectorUserScene`: one of the SDK's user test vectors (`test-vectors/users/*.json`), stored as it is: the
|
||||
//! account and its vault are already encrypted. Unlike upstream, the address isn't mangled, since our master
|
||||
//! password salt is the email, so a vector can be seeded once at a time. Text Sends are stored too.
|
||||
//!
|
||||
//! Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/util/SeederApi/Controllers/SeedController.cs#L11-L107>
|
||||
//! Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/util/Seeder/Scenes/SingleUserScene.cs#L38-L154>
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
num::NonZeroU32,
|
||||
sync::{LazyLock, Mutex},
|
||||
};
|
||||
|
||||
use chrono::Utc;
|
||||
use data_encoding::{BASE64, HEXLOWER};
|
||||
use openssl::{
|
||||
pkey::PKey,
|
||||
rsa::Rsa,
|
||||
symm::{Cipher, encrypt},
|
||||
};
|
||||
use ring::{hkdf, hmac, pbkdf2};
|
||||
use rocket::{
|
||||
Request, Route,
|
||||
http::Status,
|
||||
request::{FromRequest, Outcome},
|
||||
serde::json::Json,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
ApiResult, JsonResult,
|
||||
core::{seed_cipher, seed_send},
|
||||
},
|
||||
crypto,
|
||||
db::{
|
||||
DbConn,
|
||||
models::{Folder, FolderId, KeyId, SignatureAlgorithm, User, UserId, UserKdfType, UserSignatureKeyPair},
|
||||
},
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![seed, destroy_play]
|
||||
}
|
||||
|
||||
/// The accounts seeded under each play id.
|
||||
static PLAYS: LazyLock<Mutex<HashMap<String, Vec<UserId>>>> = LazyLock::new(Mutex::default);
|
||||
|
||||
/// HTTP basic auth with `TEST_SEEDER_USERNAME` and `TEST_SEEDER_PASSWORD`.
|
||||
struct SeederAuth;
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for SeederAuth {
|
||||
type Error = &'static str;
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let (Ok(username), Ok(password)) =
|
||||
(std::env::var("TEST_SEEDER_USERNAME"), std::env::var("TEST_SEEDER_PASSWORD"))
|
||||
else {
|
||||
return Outcome::Error((Status::NotFound, "The test seeder is off"));
|
||||
};
|
||||
if username.is_empty() || password.is_empty() {
|
||||
return Outcome::Error((Status::NotFound, "The test seeder is off"));
|
||||
}
|
||||
let expected = format!("Basic {}", BASE64.encode(format!("{username}:{password}").as_bytes()));
|
||||
match request.headers().get_one("Authorization") {
|
||||
Some(given) if crypto::ct_eq(given, expected) => Outcome::Success(Self),
|
||||
_ => Outcome::Error((Status::Unauthorized, "Invalid test seeder credentials")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `X-Play-Id` header, which names the play a seeding belongs to.
|
||||
struct PlayId(String);
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for PlayId {
|
||||
type Error = &'static str;
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
match request.headers().get_one("X-Play-Id").map(str::trim) {
|
||||
Some(play_id) if !play_id.is_empty() => Outcome::Success(Self(play_id.to_owned())),
|
||||
_ => Outcome::Error((Status::BadRequest, "Seeding needs an X-Play-Id header")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SeedRequest {
|
||||
template: String,
|
||||
arguments: Option<Value>,
|
||||
}
|
||||
|
||||
#[post("/seed", data = "<data>")]
|
||||
async fn seed(data: Json<SeedRequest>, _auth: SeederAuth, play: PlayId, conn: DbConn) -> JsonResult {
|
||||
let data = data.into_inner();
|
||||
let arguments = data.arguments.unwrap_or(Value::Null);
|
||||
let response = match data.template.as_str() {
|
||||
"SingleUserScene" => single_user_scene(serde_json::from_value(arguments)?, &play.0, &conn).await?,
|
||||
"TestVectorUserScene" => test_vector_user_scene(serde_json::from_value(arguments)?, &play.0, &conn).await?,
|
||||
other => err_code!(format!("Unknown scene {}", other.escape_debug()), Status::NotFound.code),
|
||||
};
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
/// Records an account in its play as soon as it's saved, so that destroying the play removes it even when the rest of
|
||||
/// the seeding fails.
|
||||
fn track(play: &str, user_id: &UserId) {
|
||||
PLAYS.lock().unwrap().entry(play.to_owned()).or_default().push(user_id.clone());
|
||||
}
|
||||
|
||||
/// Deletes the accounts seeded under `play_id`; an unknown play is a no-op, like upstream.
|
||||
#[delete("/seed/<play_id>")]
|
||||
async fn destroy_play(play_id: &str, _auth: SeederAuth, conn: DbConn) -> JsonResult {
|
||||
let user_ids = PLAYS.lock().unwrap().remove(play_id).unwrap_or_default();
|
||||
let mut remaining = Vec::new();
|
||||
let mut error = None;
|
||||
for user_id in &user_ids {
|
||||
let Some(user) = User::find_by_uuid(user_id, &conn).await else {
|
||||
continue;
|
||||
};
|
||||
if let Err(e) = user.delete(&conn).await {
|
||||
remaining.push(user_id.clone());
|
||||
error.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
if let Some(error) = error {
|
||||
// Kept in the play, so that destroying it again retries them
|
||||
PLAYS.lock().unwrap().entry(play_id.to_owned()).or_default().extend(remaining);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(Json(json!({ "userIds": user_ids })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SingleUserRequest {
|
||||
email: String,
|
||||
password: String,
|
||||
#[serde(default)]
|
||||
email_verified: bool,
|
||||
kdf_iterations: Option<u32>,
|
||||
#[serde(default)]
|
||||
enroll_in_email2_fa: bool,
|
||||
#[serde(default)]
|
||||
corrupt_private_key: bool,
|
||||
}
|
||||
|
||||
/// Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/util/Seeder/Models/UserSeed.cs#L55>
|
||||
const DEFAULT_KDF_ITERATIONS: u32 = 5_000;
|
||||
|
||||
async fn single_user_scene(request: SingleUserRequest, play: &str, conn: &DbConn) -> ApiResult<Value> {
|
||||
if request.enroll_in_email2_fa || request.corrupt_private_key {
|
||||
err!("enrollInEmail2Fa and corruptPrivateKey aren't supported")
|
||||
}
|
||||
let iterations = request.kdf_iterations.unwrap_or(DEFAULT_KDF_ITERATIONS);
|
||||
if !(5_000..=2_000_000).contains(&iterations) {
|
||||
err!("kdfIterations must be between 5000 and 2000000")
|
||||
}
|
||||
let Some((local, domain)) = request.email.split_once('@') else {
|
||||
err!("The email has no @")
|
||||
};
|
||||
// Like upstream's ManglerService: unique addresses for every seeding
|
||||
let mangled = format!("{}+{local}@{domain}", HEXLOWER.encode(&crypto::get_random_bytes::<4>()));
|
||||
|
||||
let mut user = User::new(&mangled, None);
|
||||
let keys = V1Keys::new(&request.password, &user.master_password_salt(), iterations)?;
|
||||
user.client_kdf_type = UserKdfType::Pbkdf2 as i32;
|
||||
user.client_kdf_iter = iterations.cast_signed();
|
||||
user.client_kdf_memory = None;
|
||||
user.client_kdf_parallelism = None;
|
||||
user.private_key = Some(keys.private_key.clone());
|
||||
user.public_key = Some(keys.public_key.clone());
|
||||
if request.email_verified {
|
||||
user.verified_at = Some(Utc::now().naive_utc());
|
||||
}
|
||||
user.set_password(&keys.master_password_hash, Some(keys.wrapped_user_key.clone()), false, None, conn).await?;
|
||||
user.save(conn).await?;
|
||||
track(play, &user.uuid);
|
||||
|
||||
let response = json!({
|
||||
"mangleMap": mangle_map(&request.email, &user.email),
|
||||
"result": {
|
||||
"userId": user.uuid,
|
||||
"kdf": "PBKDF2_SHA256",
|
||||
"kdfIterations": iterations,
|
||||
"key": keys.wrapped_user_key,
|
||||
"publicKey": keys.public_key,
|
||||
"privateKey": keys.private_key,
|
||||
},
|
||||
});
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// What a V1 client makes when it registers, from the master password.
|
||||
struct V1Keys {
|
||||
/// The master password authentication hash, which the client logs in with.
|
||||
master_password_hash: String,
|
||||
/// The user key, wrapped by the stretched master key.
|
||||
wrapped_user_key: String,
|
||||
/// The PKCS#8 RSA private key, wrapped by the user key.
|
||||
private_key: String,
|
||||
/// The SPKI RSA public key.
|
||||
public_key: String,
|
||||
}
|
||||
|
||||
impl V1Keys {
|
||||
fn new(password: &str, salt: &str, iterations: u32) -> ApiResult<Self> {
|
||||
let master_key = pbkdf2_sha256(password.as_bytes(), salt.as_bytes(), iterations);
|
||||
let master_password_hash = BASE64.encode(&pbkdf2_sha256(&master_key, password.as_bytes(), 1));
|
||||
let stretched_enc = hkdf_expand(&master_key, b"enc");
|
||||
let stretched_mac = hkdf_expand(&master_key, b"mac");
|
||||
|
||||
let user_key = crypto::get_random_bytes::<64>();
|
||||
let wrapped_user_key = enc_string(&user_key, &stretched_enc, &stretched_mac)?;
|
||||
|
||||
let key_pair = PKey::from_rsa(Rsa::generate(2048)?)?;
|
||||
let private_key = enc_string(&key_pair.private_key_to_pkcs8()?, &user_key[..32], &user_key[32..])?;
|
||||
let public_key = BASE64.encode(&key_pair.public_key_to_der()?);
|
||||
|
||||
Ok(Self {
|
||||
master_password_hash,
|
||||
wrapped_user_key,
|
||||
private_key,
|
||||
public_key,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn pbkdf2_sha256(secret: &[u8], salt: &[u8], iterations: u32) -> [u8; 32] {
|
||||
let mut out = [0u8; 32];
|
||||
let iterations = NonZeroU32::new(iterations).expect("iterations aren't zero");
|
||||
pbkdf2::derive(pbkdf2::PBKDF2_HMAC_SHA256, iterations, salt, secret, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// HKDF-Expand with `key` as the pseudorandom key, as clients stretch the master key.
|
||||
fn hkdf_expand(key: &[u8; 32], info: &[u8]) -> [u8; 32] {
|
||||
let mut out = [0u8; 32];
|
||||
hkdf::Prk::new_less_safe(hkdf::HKDF_SHA256, key)
|
||||
.expand(&[info], hkdf::HKDF_SHA256)
|
||||
.and_then(|okm| okm.fill(&mut out))
|
||||
.expect("32 bytes is a valid HKDF-SHA256 length");
|
||||
out
|
||||
}
|
||||
|
||||
/// An `AesCbc256_HmacSha256_B64` (type 2) EncString.
|
||||
fn enc_string(plaintext: &[u8], enc_key: &[u8], mac_key: &[u8]) -> ApiResult<String> {
|
||||
let iv = crypto::get_random_bytes::<16>();
|
||||
let ciphertext = encrypt(Cipher::aes_256_cbc(), enc_key, Some(&iv), plaintext)?;
|
||||
let mac = hmac::sign(&hmac::Key::new(hmac::HMAC_SHA256, mac_key), &[iv.as_slice(), &ciphertext].concat());
|
||||
Ok(format!("2.{}|{}|{}", BASE64.encode(&iv), BASE64.encode(&ciphertext), BASE64.encode(mac.as_ref())))
|
||||
}
|
||||
|
||||
/// A user test vector; only what a server holds is read from it.
|
||||
///
|
||||
/// Ref: <https://github.com/bitwarden/sdk-internal/blob/ee307a55d5604e96b983595c6379cc9f76ca2f87/e2e/wasm/vectors/load.ts>
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TestVector {
|
||||
name: String,
|
||||
account: VectorAccount,
|
||||
unlock_methods: Vec<Value>,
|
||||
master_password_authentication_hash: Option<String>,
|
||||
raw_cryptographic_state: VectorRawCryptographicState,
|
||||
#[serde(default)]
|
||||
vault: VectorVault,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VectorAccount {
|
||||
email: String,
|
||||
security_version: Option<i32>,
|
||||
account_cryptographic_state: Value,
|
||||
}
|
||||
|
||||
/// The public halves the vector records; its private halves are never read.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VectorRawCryptographicState {
|
||||
public_key: String,
|
||||
verifying_key: Option<String>,
|
||||
user_key_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct VectorVault {
|
||||
#[serde(default)]
|
||||
ciphers: Vec<VectorItem>,
|
||||
#[serde(default)]
|
||||
folders: Vec<VectorItem>,
|
||||
#[serde(default)]
|
||||
sends: Vec<VectorItem>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct VectorItem {
|
||||
id: String,
|
||||
encrypted: Value,
|
||||
}
|
||||
|
||||
/// Upstream's scene, not merged there yet:
|
||||
/// <https://github.com/bitwarden/server/blob/9614f3f54f78ad57090a1fd5228f9c2fac5cbfe2/util/Seeder/Scenes/TestVectorUserScene.cs#L43-L361>
|
||||
async fn test_vector_user_scene(vector: TestVector, play: &str, conn: &DbConn) -> ApiResult<Value> {
|
||||
let Some(unlock) =
|
||||
vector.unlock_methods.iter().find_map(|m| m.pointer("/masterPasswordUnlock/master_password_unlock"))
|
||||
else {
|
||||
err!(format!("Vector {} has no master password unlock method", vector.name.escape_debug()))
|
||||
};
|
||||
let Some(hash) = vector.master_password_authentication_hash.as_deref() else {
|
||||
err!(format!("Vector {} records no master password authentication hash", vector.name.escape_debug()))
|
||||
};
|
||||
let (Some(wrapped_user_key), Some(salt)) = (unlock["masterKeyWrappedUserKey"].as_str(), unlock["salt"].as_str())
|
||||
else {
|
||||
err!("The master password unlock method has no wrapped user key or salt")
|
||||
};
|
||||
|
||||
let mut user = User::new(&vector.account.email, Some(vector.name.clone()));
|
||||
if user.master_password_salt() != salt {
|
||||
err!("The vector's master password salt isn't its email, which is the only salt Vaultwarden supports")
|
||||
}
|
||||
if User::find_by_mail(&user.email, conn).await.is_some() {
|
||||
err!("This vector is already seeded: destroy its play first, its address can't be mangled")
|
||||
}
|
||||
|
||||
apply_kdf(&mut user, &unlock["kdf"])?;
|
||||
let state = &vector.account.account_cryptographic_state;
|
||||
let (v2, private_key) = match (state.get("V1"), state.get("V2")) {
|
||||
(Some(v1), None) => (None, &v1["private_key"]),
|
||||
(None, Some(v2)) => (Some(v2), &v2["private_key"]),
|
||||
_ => err!("The account cryptographic state is neither V1 nor V2"),
|
||||
};
|
||||
user.private_key = Some(json_str(private_key, "private_key")?);
|
||||
user.public_key = Some(vector.raw_cryptographic_state.public_key.clone());
|
||||
let key_id = vector.raw_cryptographic_state.user_key_id.as_deref().or_else(|| unlock["containedKeyId"].as_str());
|
||||
user.key_id = key_id.map(|id| serde_json::from_value::<KeyId>(json!(id))).transpose()?;
|
||||
let signature_key_pair = match v2 {
|
||||
Some(v2) => {
|
||||
user.signed_public_key = Some(json_str(&v2["signed_public_key"], "signed_public_key")?);
|
||||
user.security_state = Some(json_str(&v2["security_state"], "security_state")?);
|
||||
user.security_version = Some(vector.account.security_version.unwrap_or(2));
|
||||
let Some(verifying_key) = vector.raw_cryptographic_state.verifying_key.clone() else {
|
||||
err!("A V2 vector needs its verifying key")
|
||||
};
|
||||
let algorithm = cose_signature_algorithm(&verifying_key)?;
|
||||
Some((algorithm, json_str(&v2["signing_key"], "signing_key")?, verifying_key))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
user.verified_at = Some(Utc::now().naive_utc());
|
||||
user.set_password(hash, Some(wrapped_user_key.to_owned()), false, None, conn).await?;
|
||||
user.save(conn).await?;
|
||||
track(play, &user.uuid);
|
||||
if let Some((algorithm, signing_key, verifying_key)) = signature_key_pair {
|
||||
UserSignatureKeyPair::new(user.uuid.clone(), algorithm, signing_key, verifying_key).save(conn).await?;
|
||||
}
|
||||
|
||||
// The vault, already encrypted, under fresh ids
|
||||
let mut folder_ids = HashMap::new();
|
||||
let mut folder_ids_by_vector_id = serde_json::Map::new();
|
||||
for folder in &vector.vault.folders {
|
||||
let mut saved = Folder::new(user.uuid.clone(), json_str(&folder.encrypted["name"], "name")?);
|
||||
saved.save(conn).await?;
|
||||
folder_ids.insert(serde_json::from_value::<FolderId>(json!(folder.id))?, saved.uuid.clone());
|
||||
folder_ids_by_vector_id.insert(folder.id.clone(), json!(saved.uuid));
|
||||
}
|
||||
let mut cipher_ids = serde_json::Map::new();
|
||||
for cipher in vector.vault.ciphers {
|
||||
let cipher_id = seed_cipher(cipher.encrypted, &user.uuid, &folder_ids, conn).await?;
|
||||
cipher_ids.insert(cipher.id, json!(cipher_id));
|
||||
}
|
||||
for send in vector.vault.sends {
|
||||
seed_send(send.encrypted, &user.uuid, conn).await?;
|
||||
}
|
||||
|
||||
let response = json!({
|
||||
"mangleMap": mangle_map(&vector.account.email, &user.email),
|
||||
"result": {
|
||||
"userId": user.uuid,
|
||||
"email": user.email,
|
||||
"cipherIds": cipher_ids,
|
||||
"folderIds": folder_ids_by_vector_id,
|
||||
},
|
||||
});
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// The vector's KDF: `{"pBKDF2": {"iterations"}}` or `{"argon2id": {"iterations", "memory", "parallelism"}}`.
|
||||
fn apply_kdf(user: &mut User, kdf: &Value) -> ApiResult<()> {
|
||||
let int = |value: &Value| value.as_i64().and_then(|v| i32::try_from(v).ok());
|
||||
if let Some(iterations) = kdf.pointer("/pBKDF2/iterations").and_then(int) {
|
||||
user.client_kdf_type = UserKdfType::Pbkdf2 as i32;
|
||||
user.client_kdf_iter = iterations;
|
||||
user.client_kdf_memory = None;
|
||||
user.client_kdf_parallelism = None;
|
||||
} else if let Some(argon2) = kdf.get("argon2id") {
|
||||
let Some(iterations) = int(&argon2["iterations"]) else {
|
||||
err!("The Argon2id KDF has no iterations")
|
||||
};
|
||||
user.client_kdf_type = UserKdfType::Argon2id as i32;
|
||||
user.client_kdf_iter = iterations;
|
||||
user.client_kdf_memory = int(&argon2["memory"]);
|
||||
user.client_kdf_parallelism = int(&argon2["parallelism"]);
|
||||
} else {
|
||||
err!("Unknown KDF")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The signature algorithm a COSE verifying key names in its `alg` (label 3) entry.
|
||||
fn cose_signature_algorithm(verifying_key: &str) -> ApiResult<SignatureAlgorithm> {
|
||||
const ALG: i128 = 3;
|
||||
const EDDSA: i128 = -8;
|
||||
const ML_DSA_44: i128 = -48;
|
||||
|
||||
let Ok(bytes) = BASE64.decode(verifying_key.as_bytes()) else {
|
||||
err!("The verifying key isn't base64")
|
||||
};
|
||||
let Ok(serde_cbor_2::Value::Map(entries)) = serde_cbor_2::from_slice::<serde_cbor_2::Value>(&bytes) else {
|
||||
err!("The verifying key isn't a COSE key")
|
||||
};
|
||||
match entries.get(&serde_cbor_2::Value::Integer(ALG)) {
|
||||
Some(serde_cbor_2::Value::Integer(EDDSA)) => Ok(SignatureAlgorithm::Ed25519),
|
||||
Some(serde_cbor_2::Value::Integer(ML_DSA_44)) => Ok(SignatureAlgorithm::MlDsa44),
|
||||
_ => err!("The verifying key's algorithm isn't Ed25519 or ML-DSA-44"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Upstream's map from the address a scene was asked for to the one it stored.
|
||||
fn mangle_map(requested: &str, stored: &str) -> Value {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert(requested.to_owned(), json!(stored));
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
fn json_str(value: &Value, field: &str) -> ApiResult<String> {
|
||||
let Some(value) = value.as_str() else {
|
||||
err!(format!("The vector's {field} is missing"))
|
||||
};
|
||||
Ok(value.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use openssl::symm::decrypt;
|
||||
|
||||
use super::*;
|
||||
|
||||
// From sdk-internal's `test-vectors/users/v1-pbkdf2-min-iterations.json` (d9119f7af)
|
||||
const EMAIL: &str = "v1-pbkdf2-min-iterations@test.bitwarden.com";
|
||||
const PASSWORD: &str = "password-v1-pbkdf2-min-iterations";
|
||||
const ITERATIONS: u32 = 5_000;
|
||||
const AUTHENTICATION_HASH: &str = "4HcwTg4bQKEbuINCJGMqrij1yipl63Ugu/RazH2y7Qw=";
|
||||
const WRAPPED_USER_KEY: &str = "2.tl+DrZdn+fVyBVu+0c/Gvg==|9ivOLNtKbvirNaUnkGsEtysp9de53ibTSu88HLkDOGLig3t8nhvK5JIqxSY1/ukcr2Y/Ly9OJ12tXUGjIlJ/PtIEIdYj3gZDwhAflPUwtW8=|FD5WuIvviib3yWYcI3slmqBSqMf38Z0CMwttPG1w5DY=";
|
||||
const USER_KEY: &str = "49LghK4AVIybWSZFikb4nAn/BofxxBIoXv83EJWOP4taVMbR5XCfrbF2m/eUmfZdeFW7VybPYSGT/bmv5eeMBg==";
|
||||
|
||||
/// Decrypts a type 2 EncString, checking its MAC.
|
||||
fn dec_string(enc_string: &str, enc_key: &[u8], mac_key: &[u8]) -> Vec<u8> {
|
||||
let parts: Vec<Vec<u8>> = enc_string
|
||||
.strip_prefix("2.")
|
||||
.unwrap()
|
||||
.split('|')
|
||||
.map(|part| BASE64.decode(part.as_bytes()).unwrap())
|
||||
.collect();
|
||||
let [iv, ciphertext, mac] = parts.as_slice() else {
|
||||
panic!("not three parts")
|
||||
};
|
||||
let key = hmac::Key::new(hmac::HMAC_SHA256, mac_key);
|
||||
hmac::verify(&key, &[iv.as_slice(), ciphertext].concat(), mac).unwrap();
|
||||
decrypt(Cipher::aes_256_cbc(), enc_key, Some(iv), ciphertext).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derives_the_vectors_keys() {
|
||||
let master_key = pbkdf2_sha256(PASSWORD.as_bytes(), EMAIL.as_bytes(), ITERATIONS);
|
||||
assert_eq!(BASE64.encode(&pbkdf2_sha256(&master_key, PASSWORD.as_bytes(), 1)), AUTHENTICATION_HASH);
|
||||
|
||||
let user_key =
|
||||
dec_string(WRAPPED_USER_KEY, &hkdf_expand(&master_key, b"enc"), &hkdf_expand(&master_key, b"mac"));
|
||||
assert_eq!(BASE64.encode(&user_key), USER_KEY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn makes_keys_a_client_can_open() {
|
||||
let keys = V1Keys::new(PASSWORD, EMAIL, ITERATIONS).unwrap();
|
||||
assert_eq!(keys.master_password_hash, AUTHENTICATION_HASH);
|
||||
|
||||
let master_key = pbkdf2_sha256(PASSWORD.as_bytes(), EMAIL.as_bytes(), ITERATIONS);
|
||||
let user_key =
|
||||
dec_string(&keys.wrapped_user_key, &hkdf_expand(&master_key, b"enc"), &hkdf_expand(&master_key, b"mac"));
|
||||
assert_eq!(user_key.len(), 64);
|
||||
|
||||
let pkcs8 = dec_string(&keys.private_key, &user_key[..32], &user_key[32..]);
|
||||
let private_key = PKey::private_key_from_pkcs8(&pkcs8).unwrap();
|
||||
assert_eq!(BASE64.encode(&private_key.public_key_to_der().unwrap()), keys.public_key);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user