Compare commits
17 Commits
d156c75afc
...
790cfba9e9
| Author | SHA1 | Date |
|---|---|---|
|
|
790cfba9e9 | 2 weeks ago |
|
|
a2e285248e | 2 weeks ago |
|
|
274ee69db3 | 2 weeks ago |
|
|
46f4ec83df | 2 weeks ago |
|
|
df2cd3c869 | 3 days ago |
|
|
0e93d15b73 | 3 days ago |
|
|
d1cbd027cd | 3 days ago |
|
|
1f802f8e6a | 4 days ago |
|
|
a2efadc650 | 4 days ago |
|
|
acbf49018f | 4 days ago |
|
|
b8089e31c2 | 4 days ago |
|
|
8647af8f89 | 4 days ago |
|
|
c687cacb6b | 4 days ago |
|
|
8b56926077 | 4 days ago |
|
|
de6d2066b3 | 4 days ago |
|
|
415df400f4 | 4 days ago |
|
|
42aa3ee1c7 | 4 days ago |
68 changed files with 3039 additions and 822 deletions
@ -0,0 +1,65 @@ |
|||
name: SDK live tests |
|||
permissions: {} |
|||
|
|||
# Runs the live-server integration tests of bitwarden/sdk-internal against this branch. |
|||
# Only started by hand: it builds the SDK too, and the tests aren't in a released SDK yet. |
|||
on: |
|||
workflow_dispatch: |
|||
inputs: |
|||
sdk_ref: |
|||
description: "Branch, tag or commit of bitwarden/sdk-internal to test with" |
|||
required: true |
|||
default: "km/live-server-integration-tests" |
|||
|
|||
defaults: |
|||
run: |
|||
shell: bash |
|||
|
|||
jobs: |
|||
sdk-live-tests: |
|||
name: SDK live tests |
|||
runs-on: ubuntu-24.04 |
|||
timeout-minutes: 90 |
|||
steps: |
|||
- name: "Install dependencies Ubuntu" |
|||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential libssl-dev pkg-config |
|||
|
|||
- name: "Checkout" |
|||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
|||
with: |
|||
persist-credentials: false |
|||
|
|||
- name: "Checkout sdk-internal" |
|||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
|||
with: |
|||
repository: bitwarden/sdk-internal |
|||
ref: ${{ inputs.sdk_ref }} |
|||
path: sdk-internal |
|||
persist-credentials: false |
|||
|
|||
# Each checkout pins its own toolchain in rust-toolchain.toml |
|||
- name: "Install toolchains" |
|||
run: | |
|||
rustup toolchain install |
|||
cd sdk-internal |
|||
rustup toolchain install |
|||
rustup target add wasm32-unknown-unknown |
|||
rustup component add rust-src |
|||
|
|||
- name: "Setup Node" |
|||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 |
|||
with: |
|||
node-version: 20 |
|||
|
|||
- name: "Install binaryen" |
|||
run: npm i -g binaryen@132.0.0 |
|||
|
|||
- name: "Run the SDK live tests" |
|||
run: tools/sdk-live-tests/run.sh --sdk-dir sdk-internal |
|||
|
|||
- name: "Upload logs" |
|||
if: ${{ failure() }} |
|||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 |
|||
with: |
|||
name: sdk-live-tests-logs |
|||
path: target/sdk-live-tests/**/*.log |
|||
@ -0,0 +1 @@ |
|||
ALTER TABLE org_policies DROP COLUMN revision_date; |
|||
@ -0,0 +1,7 @@ |
|||
-- DATETIME (not TIMESTAMP) to match this repo's convention for revision_date |
|||
-- columns elsewhere, and to avoid MySQL's implicit session-timezone |
|||
-- conversion and 2038 range limit on TIMESTAMP. |
|||
ALTER TABLE org_policies |
|||
ADD COLUMN revision_date DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00'; |
|||
|
|||
UPDATE org_policies SET revision_date = UTC_TIMESTAMP(); |
|||
@ -0,0 +1,3 @@ |
|||
-- A blob-encrypted cipher keeps all of its content in `data`, up to 500,000 characters, so the |
|||
-- 64 KiB of TEXT is too small. Like upstream, which uses LONGTEXT. |
|||
ALTER TABLE ciphers MODIFY data LONGTEXT NOT NULL; |
|||
@ -0,0 +1,5 @@ |
|||
DROP TABLE IF EXISTS user_signature_key_pairs; |
|||
|
|||
ALTER TABLE users DROP COLUMN signed_public_key; |
|||
ALTER TABLE users DROP COLUMN security_state; |
|||
ALTER TABLE users DROP COLUMN security_version; |
|||
@ -0,0 +1,14 @@ |
|||
ALTER TABLE users ADD COLUMN signed_public_key TEXT; |
|||
ALTER TABLE users ADD COLUMN security_state TEXT; |
|||
ALTER TABLE users ADD COLUMN security_version INTEGER; |
|||
|
|||
CREATE TABLE user_signature_key_pairs ( |
|||
uuid CHAR(36) NOT NULL PRIMARY KEY, |
|||
user_uuid CHAR(36) NOT NULL UNIQUE, |
|||
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44 |
|||
signing_key TEXT NOT NULL, |
|||
verifying_key TEXT NOT NULL, |
|||
created_at DATETIME NOT NULL, |
|||
updated_at DATETIME NOT NULL, |
|||
FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE |
|||
); |
|||
@ -0,0 +1,2 @@ |
|||
ALTER TABLE users DROP COLUMN v2_upgrade_token; |
|||
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token; |
|||
@ -0,0 +1,2 @@ |
|||
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT; |
|||
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT; |
|||
@ -0,0 +1 @@ |
|||
ALTER TABLE org_policies DROP COLUMN revision_date; |
|||
@ -0,0 +1,9 @@ |
|||
-- Backfill via `now() AT TIME ZONE 'utc'` rather than a DEFAULT of now(): |
|||
-- assigning timestamptz now() into a naive TIMESTAMP column casts through |
|||
-- the server's TimeZone GUC, so a DEFAULT now() would store local wall-clock |
|||
-- instead of UTC on non-UTC servers, unlike every other naive-UTC timestamp |
|||
-- column in this schema. |
|||
ALTER TABLE org_policies |
|||
ADD COLUMN revision_date TIMESTAMP NOT NULL DEFAULT '1970-01-01 00:00:00'; |
|||
|
|||
UPDATE org_policies SET revision_date = (now() AT TIME ZONE 'utc'); |
|||
@ -0,0 +1 @@ |
|||
-- TEXT has no size limit here, only MySQL needed the change |
|||
@ -0,0 +1,5 @@ |
|||
DROP TABLE IF EXISTS user_signature_key_pairs; |
|||
|
|||
ALTER TABLE users DROP COLUMN signed_public_key; |
|||
ALTER TABLE users DROP COLUMN security_state; |
|||
ALTER TABLE users DROP COLUMN security_version; |
|||
@ -0,0 +1,13 @@ |
|||
ALTER TABLE users ADD COLUMN signed_public_key TEXT; |
|||
ALTER TABLE users ADD COLUMN security_state TEXT; |
|||
ALTER TABLE users ADD COLUMN security_version INTEGER; |
|||
|
|||
CREATE TABLE user_signature_key_pairs ( |
|||
uuid CHAR(36) NOT NULL PRIMARY KEY, |
|||
user_uuid CHAR(36) NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE, |
|||
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44 |
|||
signing_key TEXT NOT NULL, |
|||
verifying_key TEXT NOT NULL, |
|||
created_at TIMESTAMP NOT NULL, |
|||
updated_at TIMESTAMP NOT NULL |
|||
); |
|||
@ -0,0 +1,2 @@ |
|||
ALTER TABLE users DROP COLUMN v2_upgrade_token; |
|||
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token; |
|||
@ -0,0 +1,2 @@ |
|||
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT; |
|||
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT; |
|||
@ -0,0 +1,6 @@ |
|||
-- SQLite forbids non-constant defaults in ALTER TABLE ... ADD COLUMN, so add |
|||
-- the column with a constant placeholder and backfill separately. |
|||
ALTER TABLE org_policies |
|||
ADD COLUMN revision_date DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00'; |
|||
|
|||
UPDATE org_policies SET revision_date = CURRENT_TIMESTAMP; |
|||
@ -0,0 +1 @@ |
|||
-- TEXT has no size limit here, only MySQL needed the change |
|||
@ -0,0 +1,5 @@ |
|||
DROP TABLE IF EXISTS user_signature_key_pairs; |
|||
|
|||
ALTER TABLE users DROP COLUMN signed_public_key; |
|||
ALTER TABLE users DROP COLUMN security_state; |
|||
ALTER TABLE users DROP COLUMN security_version; |
|||
@ -0,0 +1,13 @@ |
|||
ALTER TABLE users ADD COLUMN signed_public_key TEXT; |
|||
ALTER TABLE users ADD COLUMN security_state TEXT; |
|||
ALTER TABLE users ADD COLUMN security_version INTEGER; |
|||
|
|||
CREATE TABLE user_signature_key_pairs ( |
|||
uuid TEXT NOT NULL PRIMARY KEY, |
|||
user_uuid TEXT NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE, |
|||
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44 |
|||
signing_key TEXT NOT NULL, |
|||
verifying_key TEXT NOT NULL, |
|||
created_at DATETIME NOT NULL, |
|||
updated_at DATETIME NOT NULL |
|||
); |
|||
@ -0,0 +1,2 @@ |
|||
ALTER TABLE users DROP COLUMN v2_upgrade_token; |
|||
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token; |
|||
@ -0,0 +1,2 @@ |
|||
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT; |
|||
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT; |
|||
@ -0,0 +1,131 @@ |
|||
import { test, expect, type Page, type TestInfo, Test } from '@playwright/test'; |
|||
import { MailDev } from 'maildev'; |
|||
|
|||
import * as utils from "../global-utils"; |
|||
import { createAccount, logUser } from './setups/user'; |
|||
import { activateTOTP } from './setups/2fa'; |
|||
|
|||
let users = utils.loadEnv(); |
|||
let mailserver; |
|||
|
|||
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { |
|||
mailserver = new MailDev({ |
|||
port: process.env.MAILDEV_SMTP_PORT, |
|||
web: { port: process.env.MAILDEV_HTTP_PORT }, |
|||
}) |
|||
|
|||
await mailserver.listen(); |
|||
|
|||
await utils.startVault(browser, testInfo, { |
|||
SMTP_HOST: process.env.MAILDEV_HOST, |
|||
SMTP_FROM: process.env.PW_SMTP_FROM, |
|||
}); |
|||
}); |
|||
|
|||
test.afterAll('Teardown', async ({}) => { |
|||
utils.stopVault(); |
|||
if( mailserver ){ |
|||
await mailserver.close(); |
|||
} |
|||
}); |
|||
|
|||
async function emergencyAccess(test: Test, page: Page, user: { name: string }) { |
|||
await test.step('Navigate', async () => { |
|||
await page.getByRole('button', { name: user.name }).click(); |
|||
await page.getByRole('menuitem', { name: 'Account settings' }).click(); |
|||
await page.getByRole('link', { name: 'Emergency access' }).click(); |
|||
await expect(page.locator('#main-content').getByText('Emergency access', { exact: true })).toBeVisible(); |
|||
}); |
|||
} |
|||
|
|||
test('Emergency access', async ({ browser, page }) => { |
|||
const context2 = await browser.newContext(); |
|||
const page2 = await context2.newPage(); |
|||
|
|||
const mailBuffer = mailserver.buffer(users.user1.email); |
|||
const mailBuffer2 = mailserver.buffer(users.user2.email); |
|||
|
|||
await createAccount(test, page, users.user1); |
|||
await createAccount(test, page2, users.user2); |
|||
|
|||
await test.step('Add test2', async () => { |
|||
await emergencyAccess(test, page, users.user1); |
|||
await page.getByRole('button', { name: 'Add emergency contact' }).click(); |
|||
await page.getByRole('textbox', { name: 'Email * (required)' }).fill(users.user2.email); |
|||
await page.getByRole('radio', { name: 'Takeover Can reset your' }).check(); |
|||
await page.getByRole('button', { name: 'Save' }).click(); |
|||
await utils.checkNotification(page, 'User(s) invited'); |
|||
}); |
|||
|
|||
await test.step('Accept', async () => { |
|||
const email = await mailBuffer2.expect((m) => m.subject === "Emergency access for " + users.user1.name); |
|||
const pageE = await context2.newPage(); |
|||
await pageE.setContent(email.html); |
|||
const link = await pageE.getByTestId("emergency").getAttribute("href"); |
|||
await pageE.close(); |
|||
|
|||
await page2.goto(link); |
|||
await utils.checkNotification(page2, 'Invitation accepted'); |
|||
}); |
|||
|
|||
await test.step('Confirm', async () => { |
|||
await emergencyAccess(test, page, users.user1); |
|||
await expect(page.locator('#main-content').getByText('Needs confirmation')).toBeVisible(); |
|||
await page.getByRole('button', { name: 'Options' }).click(); |
|||
await page.getByRole('menuitem', { name: 'Confirm' }).click(); |
|||
await page.getByRole('button', { name: 'Confirm' }).click(); |
|||
await utils.checkNotification(page, users.user2.name + ' confirmed'); |
|||
await mailBuffer2.expect((m) => m.subject === "Emergency access contact for " + users.user1.name + " confirmed"); |
|||
}); |
|||
|
|||
await test.step('Request', async () => { |
|||
await emergencyAccess(test, page2, users.user2); |
|||
await page2.getByRole('button', { name: 'Options' }).click(); |
|||
await page2.getByRole('menuitem', { name: 'Request Access' }).click(); |
|||
await page2.getByRole('button', { name: 'Request Access' }).click(); |
|||
await utils.checkNotification(page2, 'Emergency access requested'); |
|||
await mailBuffer.expect((m) => m.subject === "Emergency access request by " + users.user2.name + " initiated"); |
|||
}); |
|||
|
|||
await test.step('Approved', async () => { |
|||
await emergencyAccess(test, page, users.user1); |
|||
await page.getByRole('button', { name: 'Options' }).click(); |
|||
await page.getByRole('menuitem', { name: 'Approve' }).click(); |
|||
await page.getByRole('button', { name: 'Approve' }).click(); |
|||
await utils.checkNotification(page, 'Emergency access approved'); |
|||
await mailBuffer2.expect((m) => m.subject === "Emergency access request for " + users.user1.name + " approved"); |
|||
}); |
|||
await activateTOTP(test, page, users.user1); |
|||
|
|||
let newPassword = "TotoNewPassword"; |
|||
await test.step('Access', async () => { |
|||
await emergencyAccess(test, page2, users.user2); |
|||
await page2.getByRole('button', { name: 'Options' }).click(); |
|||
await page2.getByRole('menuitem', { name: 'Takeover' }).click(); |
|||
await page2.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword); |
|||
await page2.getByRole('textbox', { name: 'Confirm new master password' }).fill(newPassword); |
|||
await page2.getByRole('button', { name: 'Save' }).click(); |
|||
await utils.checkNotification(page2, 'Password reset for ' + users.user1.name); |
|||
}); |
|||
|
|||
await test.step('Changed no 2fa', async () => { |
|||
users.user1.password = newPassword; |
|||
await logUser(test, page, users.user1); |
|||
}); |
|||
|
|||
await test.step('Reject', async () => { |
|||
await emergencyAccess(test, page, users.user1); |
|||
await page.getByRole('button', { name: 'Options' }).click(); |
|||
await page.getByRole('menuitem', { name: 'Reject' }).click(); |
|||
await utils.checkNotification(page, 'Emergency access rejected'); |
|||
await mailBuffer2.expect((m) => m.subject === "Emergency access request to " + users.user1.name + " rejected"); |
|||
}); |
|||
|
|||
await test.step('Remove', async () => { |
|||
await page.getByRole('button', { name: 'Options' }).click(); |
|||
await page.getByRole('menuitem', { name: 'Remove' }).click(); |
|||
await page.getByRole('button', { name: 'Yes' }).click(); |
|||
await utils.checkNotification(page, 'Removed user ' + users.user2.name); |
|||
await expect(page.getByText('You have not added any emergency contacts')).toBeVisible(); |
|||
}); |
|||
}); |
|||
File diff suppressed because it is too large
@ -0,0 +1,281 @@ |
|||
use chrono::{TimeDelta, Utc}; |
|||
use serde::{de::DeserializeOwned, ser::Serialize}; |
|||
use std::sync::LazyLock; |
|||
|
|||
use crate::{ |
|||
CONFIG, |
|||
api::{ApiResult, EmptyResult}, |
|||
auth::{decode_jwt, encode_jwt}, |
|||
db::models::UserId, |
|||
}; |
|||
|
|||
static JWT_2FA_AUTH_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|api.2fa", CONFIG.domain_origin())); |
|||
|
|||
#[derive(Serialize, Deserialize)] |
|||
pub struct TwoFactorClaims<T> { |
|||
// Not before
|
|||
pub nbf: i64, |
|||
// Expiration time
|
|||
pub exp: i64, |
|||
// Issuer
|
|||
pub iss: String, |
|||
// Subject
|
|||
pub sub: UserId, |
|||
|
|||
pub enabled: bool, |
|||
|
|||
pub claims: T, |
|||
} |
|||
|
|||
#[derive(Serialize, Deserialize)] |
|||
#[serde(deny_unknown_fields)] |
|||
pub struct AuthenticatorClaims { |
|||
#[serde(rename = "authenticator_key")] |
|||
pub key: String, |
|||
} |
|||
|
|||
#[derive(Serialize, Deserialize)] |
|||
#[serde(deny_unknown_fields)] |
|||
pub struct DuoClaims { |
|||
#[serde(rename = "duo_data")] |
|||
pub data: Option<DuoData>, |
|||
} |
|||
|
|||
#[derive(Serialize, Deserialize)] |
|||
#[serde(deny_unknown_fields)] |
|||
pub struct WebauthnClaims { |
|||
#[serde(rename = "webauthn_keys")] |
|||
pub keys: Vec<i32>, |
|||
} |
|||
|
|||
#[derive(Serialize, Deserialize)] |
|||
#[serde(deny_unknown_fields)] |
|||
pub struct YubikeyClaims { |
|||
#[serde(rename = "yubi_keys")] |
|||
pub keys: Vec<String>, |
|||
} |
|||
|
|||
#[derive(Serialize, Deserialize, PartialEq)] |
|||
pub struct DuoData { |
|||
pub host: String, // Duo API hostname
|
|||
pub ik: String, // client id
|
|||
pub sk: String, // client secret
|
|||
} |
|||
|
|||
impl DuoData { |
|||
pub fn global() -> Option<Self> { |
|||
match (CONFIG._enable_duo(), CONFIG.duo_host()) { |
|||
(true, Some(host)) => Some(Self { |
|||
host, |
|||
ik: CONFIG.duo_ikey().unwrap(), |
|||
sk: CONFIG.duo_skey().unwrap(), |
|||
}), |
|||
_ => None, |
|||
} |
|||
} |
|||
pub fn msg(s: &str) -> Self { |
|||
Self { |
|||
host: s.into(), |
|||
ik: s.into(), |
|||
sk: s.into(), |
|||
} |
|||
} |
|||
pub fn secret() -> Self { |
|||
Self::msg("<global_secret>") |
|||
} |
|||
pub fn obscure(self) -> Self { |
|||
let mut host = self.host; |
|||
let mut ik = self.ik; |
|||
let mut sk = self.sk; |
|||
|
|||
let digits = 4; |
|||
let replaced = "************"; |
|||
|
|||
host.replace_range(digits.., replaced); |
|||
ik.replace_range(digits.., replaced); |
|||
sk.replace_range(digits.., replaced); |
|||
|
|||
Self { |
|||
host, |
|||
ik, |
|||
sk, |
|||
} |
|||
} |
|||
} |
|||
|
|||
#[derive(Serialize, Deserialize)] |
|||
#[serde(deny_unknown_fields)] |
|||
pub struct EmailClaims { |
|||
pub email: Option<String>, |
|||
} |
|||
|
|||
fn token<T: Serialize>(user_id: UserId, enabled: bool, claims: T) -> String { |
|||
let time_now = Utc::now(); |
|||
let claims = TwoFactorClaims { |
|||
nbf: time_now.timestamp(), |
|||
exp: (time_now + TimeDelta::try_minutes(5).unwrap()).timestamp(), |
|||
iss: JWT_2FA_AUTH_ISSUER.to_string(), |
|||
sub: user_id, |
|||
enabled, |
|||
claims, |
|||
}; |
|||
encode_jwt(&claims) |
|||
} |
|||
|
|||
fn validate<T: DeserializeOwned>(token: &str, user_id: &UserId, enabled: bool) -> ApiResult<T> { |
|||
match decode_jwt::<TwoFactorClaims<T>>(token, JWT_2FA_AUTH_ISSUER.to_string()) { |
|||
Ok(claims) => { |
|||
if claims.sub != *user_id { |
|||
err!("Invalid verification token: Invalid user"); |
|||
} |
|||
if claims.enabled != enabled { |
|||
err!("Invalid verification token: Invalid state"); |
|||
} |
|||
Ok(claims.claims) |
|||
} |
|||
Err(err) => err!(format!("Failed to decode verification token: {err}")), |
|||
} |
|||
} |
|||
|
|||
pub fn authenticator_token(user_id: UserId, key: String, enabled: bool) -> String { |
|||
token( |
|||
user_id, |
|||
enabled, |
|||
AuthenticatorClaims { |
|||
key, |
|||
}, |
|||
) |
|||
} |
|||
|
|||
pub fn validate_authenticator(token: &str, user_id: &UserId, key: &str, enabled: bool) -> EmptyResult { |
|||
let claims = validate::<AuthenticatorClaims>(token, user_id, enabled)?; |
|||
if claims.key != key { |
|||
err!("Invalid verification token: Invalid key"); |
|||
} |
|||
Ok(()) |
|||
} |
|||
|
|||
pub fn duo_token(user_id: UserId, data: Option<DuoData>, enabled: bool) -> String { |
|||
token( |
|||
user_id, |
|||
enabled, |
|||
DuoClaims { |
|||
data, |
|||
}, |
|||
) |
|||
} |
|||
|
|||
// When disabling we check that it's the correct data
|
|||
pub fn validate_duo(token: &str, user_id: &UserId, data: Option<&DuoData>, enabled: bool) -> EmptyResult { |
|||
let claims = validate::<DuoClaims>(token, user_id, enabled)?; |
|||
if enabled && claims.data.as_ref() != data { |
|||
err!("Invalid verification token: Invalid duo data"); |
|||
} |
|||
Ok(()) |
|||
} |
|||
|
|||
pub fn email_token(user_id: UserId, email: Option<String>, enabled: bool) -> String { |
|||
token( |
|||
user_id, |
|||
enabled, |
|||
EmailClaims { |
|||
email, |
|||
}, |
|||
) |
|||
} |
|||
|
|||
// When disabling we check that it's the correct `email`
|
|||
pub fn validate_email(token: &str, user_id: &UserId, email: String, enabled: bool) -> EmptyResult { |
|||
let claims = validate::<EmailClaims>(token, user_id, enabled)?; |
|||
if enabled && claims.email != Some(email) { |
|||
err!("Invalid verification token: Invalid email"); |
|||
} |
|||
Ok(()) |
|||
} |
|||
|
|||
pub fn webauthn_token(user_id: UserId, keys: Vec<i32>, enabled: bool) -> String { |
|||
token( |
|||
user_id, |
|||
enabled, |
|||
WebauthnClaims { |
|||
keys, |
|||
}, |
|||
) |
|||
} |
|||
|
|||
pub fn validate_webauthn(token: &str, user_id: &UserId, keys: &[i32], enabled: bool) -> EmptyResult { |
|||
let claims = validate::<WebauthnClaims>(token, user_id, enabled)?; |
|||
if keys != claims.keys { |
|||
err!("Invalid verification token: Invalid keys"); |
|||
} |
|||
Ok(()) |
|||
} |
|||
|
|||
pub fn yubikey_token(user_id: UserId, keys: Vec<String>, enabled: bool) -> String { |
|||
token( |
|||
user_id, |
|||
enabled, |
|||
YubikeyClaims { |
|||
keys, |
|||
}, |
|||
) |
|||
} |
|||
|
|||
pub fn validate_yubikey(token: &str, user_id: &UserId, keys: &Vec<String>, enabled: bool) -> EmptyResult { |
|||
let claims = validate::<YubikeyClaims>(token, user_id, enabled)?; |
|||
if *keys != claims.keys { |
|||
err!("Invalid verification token: Invalid keys"); |
|||
} |
|||
Ok(()) |
|||
} |
|||
|
|||
#[cfg(test)] |
|||
mod tests { |
|||
use super::*; |
|||
use serde_json::{Value, from_value, to_value}; |
|||
|
|||
fn token<T: Serialize>(claims: T) -> Value { |
|||
to_value(TwoFactorClaims { |
|||
nbf: 0, |
|||
exp: 0, |
|||
iss: String::new(), |
|||
sub: UserId::from(String::from("4ff0f0a4-0aa4-4c1d-9d43-1f2bd4d5e8b1")), |
|||
enabled: false, |
|||
claims, |
|||
}) |
|||
.unwrap() |
|||
} |
|||
|
|||
#[test] |
|||
fn claims_only_parse_as_their_own_provider() { |
|||
let tokens = [ |
|||
token(AuthenticatorClaims { |
|||
key: String::from("JBSWY3DPEHPK3PXP"), |
|||
}), |
|||
token(DuoClaims { |
|||
data: None, |
|||
}), |
|||
token(WebauthnClaims { |
|||
keys: vec![1], |
|||
}), |
|||
token(YubikeyClaims { |
|||
keys: Vec::new(), |
|||
}), |
|||
token(EmailClaims { |
|||
email: None, |
|||
}), |
|||
]; |
|||
for (issued, token) in tokens.iter().enumerate() { |
|||
let parsed = [ |
|||
from_value::<TwoFactorClaims<AuthenticatorClaims>>(token.clone()).is_ok(), |
|||
from_value::<TwoFactorClaims<DuoClaims>>(token.clone()).is_ok(), |
|||
from_value::<TwoFactorClaims<WebauthnClaims>>(token.clone()).is_ok(), |
|||
from_value::<TwoFactorClaims<YubikeyClaims>>(token.clone()).is_ok(), |
|||
from_value::<TwoFactorClaims<EmailClaims>>(token.clone()).is_ok(), |
|||
]; |
|||
for (checked, ok) in parsed.into_iter().enumerate() { |
|||
assert_eq!(ok, issued == checked, "token {issued} parsed as {checked}"); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,132 @@ |
|||
use chrono::{NaiveDateTime, Utc}; |
|||
use derive_more::{AsRef, Deref, Display, From}; |
|||
use diesel::prelude::*; |
|||
use serde_json::Value; |
|||
|
|||
use crate::{ |
|||
api::EmptyResult, |
|||
db::{DbConn, schema::user_signature_key_pairs}, |
|||
error::MapResult, |
|||
util::get_uuid, |
|||
}; |
|||
|
|||
use super::UserId; |
|||
|
|||
/// A user's signature key pair, part of the v2 state, in its own table like upstream.
|
|||
///
|
|||
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Sql/dbo/KeyManagement/Tables/UserSignatureKeyPair.sql#L1-L15>
|
|||
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] |
|||
#[diesel(table_name = user_signature_key_pairs)] |
|||
#[diesel(treat_none_as_null = true)] |
|||
#[diesel(primary_key(uuid))] |
|||
pub struct UserSignatureKeyPair { |
|||
pub uuid: UserSignatureKeyPairId, |
|||
pub user_uuid: UserId, |
|||
|
|||
pub signature_algorithm: i32, |
|||
/// The signing (private) key, wrapped by the user key.
|
|||
pub signing_key: String, |
|||
/// The COSE-encoded public verifying key.
|
|||
pub verifying_key: String, |
|||
|
|||
pub created_at: NaiveDateTime, |
|||
pub updated_at: NaiveDateTime, |
|||
} |
|||
|
|||
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/KeyManagement/Enums/SignatureAlgorithm.cs#L6-L10>
|
|||
#[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|||
pub enum SignatureAlgorithm { |
|||
Ed25519 = 0, |
|||
MlDsa44 = 1, |
|||
} |
|||
|
|||
impl SignatureAlgorithm { |
|||
pub fn parse(algorithm: &str) -> Option<Self> { |
|||
match algorithm { |
|||
"ed25519" => Some(Self::Ed25519), |
|||
"mldsa44" => Some(Self::MlDsa44), |
|||
_ => None, |
|||
} |
|||
} |
|||
} |
|||
|
|||
/// Local methods
|
|||
impl UserSignatureKeyPair { |
|||
pub fn new( |
|||
user_uuid: UserId, |
|||
signature_algorithm: SignatureAlgorithm, |
|||
signing_key: String, |
|||
verifying_key: String, |
|||
) -> Self { |
|||
let now = Utc::now().naive_utc(); |
|||
|
|||
Self { |
|||
uuid: UserSignatureKeyPairId(get_uuid()), |
|||
user_uuid, |
|||
signature_algorithm: signature_algorithm as i32, |
|||
signing_key, |
|||
verifying_key, |
|||
created_at: now, |
|||
updated_at: now, |
|||
} |
|||
} |
|||
|
|||
pub fn to_json(&self) -> Value { |
|||
json!({ |
|||
"wrappedSigningKey": self.signing_key, |
|||
"verifyingKey": self.verifying_key, |
|||
"object": "signatureKeyPair", |
|||
}) |
|||
} |
|||
} |
|||
|
|||
/// Database methods
|
|||
impl UserSignatureKeyPair { |
|||
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { |
|||
self.updated_at = Utc::now().naive_utc(); |
|||
|
|||
db_run! { conn: |
|||
mysql { |
|||
diesel::insert_into(user_signature_key_pairs::table) |
|||
.values(&*self) |
|||
.on_conflict(diesel::dsl::DuplicatedKeys) |
|||
.do_update() |
|||
.set(&*self) |
|||
.execute(conn) |
|||
.map_res("Error saving user signature key pair") |
|||
} |
|||
postgresql, sqlite { |
|||
diesel::insert_into(user_signature_key_pairs::table) |
|||
.values(&*self) |
|||
.on_conflict(user_signature_key_pairs::user_uuid) |
|||
.do_update() |
|||
.set(&*self) |
|||
.execute(conn) |
|||
.map_res("Error saving user signature key pair") |
|||
} |
|||
} |
|||
} |
|||
|
|||
/// The user's key pair. There is at most one, enforced by a unique index on `user_uuid`.
|
|||
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<Self> { |
|||
conn.run(move |conn| { |
|||
user_signature_key_pairs::table |
|||
.filter(user_signature_key_pairs::user_uuid.eq(user_uuid)) |
|||
.first::<Self>(conn) |
|||
.ok() |
|||
}) |
|||
.await |
|||
} |
|||
|
|||
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { |
|||
conn.run(move |conn| { |
|||
diesel::delete(user_signature_key_pairs::table.filter(user_signature_key_pairs::user_uuid.eq(user_uuid))) |
|||
.execute(conn) |
|||
.map_res("Error deleting user signature key pairs") |
|||
}) |
|||
.await |
|||
} |
|||
} |
|||
|
|||
#[derive(Clone, Debug, AsRef, Deref, DieselNewType, Display, From, Hash, PartialEq, Eq, Serialize, Deserialize)] |
|||
pub struct UserSignatureKeyPairId(String); |
|||
@ -0,0 +1,129 @@ |
|||
#!/usr/bin/env python3 |
|||
"""Registers the account of an sdk-internal test vector on a vaultwarden server, as a client would. |
|||
|
|||
Usage: register_vector.py <vector.json> <server-url> |
|||
|
|||
A V1 vector is registered with the flat `keys` object, a V2 one with `accountKeys` and the user key |
|||
id. Prints the account's email and password on two lines, for the caller to log in with. |
|||
|
|||
Exits with SKIP, and the reason on stderr, for a vector the live tests can't use. |
|||
""" |
|||
|
|||
import json |
|||
import sys |
|||
import urllib.error |
|||
import urllib.request |
|||
|
|||
SKIP = 3 |
|||
MIN_PBKDF2_ITERATIONS = 100_000 |
|||
|
|||
|
|||
def skip_reason(vector): |
|||
if not any("masterPasswordUnlock" in m for m in vector["unlockMethods"]): |
|||
return "it has no master password, which the live tests log in with" |
|||
pbkdf2 = vector["account"]["kdf"].get("pBKDF2") |
|||
if pbkdf2 is not None and pbkdf2["iterations"] < MIN_PBKDF2_ITERATIONS: |
|||
return f"registration requires at least {MIN_PBKDF2_ITERATIONS} PBKDF2 iterations (upstream 600000)" |
|||
return None |
|||
|
|||
|
|||
def kdf_of(account): |
|||
kind, params = next(iter(account["kdf"].items())) |
|||
if kind == "pBKDF2": |
|||
return {"kdfType": 0, "iterations": params["iterations"]} |
|||
return { |
|||
"kdfType": 1, |
|||
"iterations": params["iterations"], |
|||
"memory": params["memory"], |
|||
"parallelism": params["parallelism"], |
|||
} |
|||
|
|||
|
|||
def register_body(vector): |
|||
account = vector["account"] |
|||
raw = vector["rawCryptographicState"] |
|||
version, state = next(iter(account["accountCryptographicState"].items())) |
|||
kdf = kdf_of(account) |
|||
unlock = next(m["masterPasswordUnlock"] for m in vector["unlockMethods"] if "masterPasswordUnlock" in m) |
|||
mp_unlock = unlock["master_password_unlock"] |
|||
|
|||
body = { |
|||
"email": account["email"], |
|||
"masterPasswordHint": None, |
|||
"masterPasswordAuthentication": { |
|||
"kdf": kdf, |
|||
"salt": mp_unlock["salt"], |
|||
"masterPasswordAuthenticationHash": vector["masterPasswordAuthenticationHash"], |
|||
}, |
|||
"masterPasswordUnlock": { |
|||
"kdf": kdf, |
|||
"salt": mp_unlock["salt"], |
|||
"masterKeyWrappedUserKey": mp_unlock["masterKeyWrappedUserKey"], |
|||
}, |
|||
} |
|||
|
|||
if version == "V2": |
|||
body["masterPasswordUnlock"]["containedKeyId"] = raw["userKeyId"] |
|||
body["accountKeys"] = { |
|||
"userKeyEncryptedAccountPrivateKey": state["private_key"], |
|||
"accountPublicKey": raw["publicKey"], |
|||
"publicKeyEncryptionKeyPair": { |
|||
"wrappedPrivateKey": state["private_key"], |
|||
"publicKey": raw["publicKey"], |
|||
"signedPublicKey": state["signed_public_key"], |
|||
}, |
|||
"signatureKeyPair": { |
|||
"signatureAlgorithm": "ed25519", |
|||
"wrappedSigningKey": state["signing_key"], |
|||
"verifyingKey": raw["verifyingKey"], |
|||
}, |
|||
"securityState": { |
|||
"securityState": state["security_state"], |
|||
"securityVersion": account["securityVersion"], |
|||
}, |
|||
} |
|||
else: |
|||
body["keys"] = {"encryptedPrivateKey": state["private_key"], "publicKey": raw["publicKey"]} |
|||
|
|||
return body, unlock["password"] |
|||
|
|||
|
|||
def post(url, body): |
|||
request = urllib.request.Request( |
|||
url, |
|||
data=json.dumps(body).encode(), |
|||
headers={"Content-Type": "application/json", "Accept": "application/json"}, |
|||
method="POST", |
|||
) |
|||
try: |
|||
with urllib.request.urlopen(request) as response: |
|||
return response.read() |
|||
except urllib.error.HTTPError as e: |
|||
sys.exit(f"Registering {body['email']} failed at {url}: {e.code} {e.read().decode()}") |
|||
|
|||
|
|||
def main(): |
|||
with open(sys.argv[1]) as f: |
|||
vector = json.load(f) |
|||
server = sys.argv[2].rstrip("/") |
|||
|
|||
reason = skip_reason(vector) |
|||
if reason is not None: |
|||
print(f"Skipped: {reason}", file=sys.stderr) |
|||
sys.exit(SKIP) |
|||
|
|||
body, password = register_body(vector) |
|||
# With signup verification off, the server returns the token instead of mailing it |
|||
token = post( |
|||
f"{server}/identity/accounts/register/send-verification-email", |
|||
{"email": body["email"], "name": vector["name"]}, |
|||
) |
|||
body["emailVerificationToken"] = json.loads(token) |
|||
post(f"{server}/identity/accounts/register/finish", body) |
|||
|
|||
print(body["email"]) |
|||
print(password) |
|||
|
|||
|
|||
if __name__ == "__main__": |
|||
main() |
|||
@ -0,0 +1,61 @@ |
|||
#!/usr/bin/env bash |
|||
# Runs the live-server integration tests of bitwarden/sdk-internal against vaultwarden. |
|||
# |
|||
# Usage: tools/sdk-live-tests/run.sh --sdk-dir <path> [--skip-sdk-build] |
|||
# |
|||
# Every test vector of the SDK (test-vectors/users/) that the tests can log in with gets a fresh |
|||
# server and database, since the tests rotate the account's keys. Stops at the first failure; the |
|||
# server logs are in target/sdk-live-tests/. Building the SDK needs its Rust toolchain with the |
|||
# wasm32-unknown-unknown target and rust-src, Node.js, and binaryen (`npm i -g binaryen@132.0.0`). |
|||
set -euo pipefail |
|||
|
|||
VW_DIR="$(cd "$(dirname "$0")/../.." && pwd)" |
|||
OUT_DIR="${VW_DIR}/target/sdk-live-tests" |
|||
URL="http://127.0.0.1:8099" |
|||
SKIP=3 # register_vector.py's exit code for a vector the tests can't use |
|||
|
|||
SDK_DIR="" |
|||
BUILD_SDK=1 |
|||
while [[ $# -gt 0 ]]; do |
|||
case "$1" in |
|||
--sdk-dir) SDK_DIR="$(cd "$2" && pwd)"; shift 2 ;; |
|||
--skip-sdk-build) BUILD_SDK=0; shift ;; |
|||
*) echo "Unknown argument: $1" >&2; exit 2 ;; |
|||
esac |
|||
done |
|||
[[ -n "${SDK_DIR}" ]] || { echo "--sdk-dir is required" >&2; exit 2; } |
|||
TESTS_DIR="${SDK_DIR}/crates/bitwarden-wasm-internal/integration-tests" |
|||
|
|||
(cd "${VW_DIR}" && cargo build --features sqlite) |
|||
if [[ ${BUILD_SDK} -eq 1 ]]; then |
|||
bash "${SDK_DIR}/crates/bitwarden-wasm-internal/build.sh" |
|||
(cd "${TESTS_DIR}" && npm ci) |
|||
fi |
|||
|
|||
trap 'kill $(jobs -p) 2>/dev/null || true' EXIT |
|||
|
|||
for vector_file in "${SDK_DIR}"/test-vectors/users/*.json; do |
|||
vector="$(basename "${vector_file}" .json)" |
|||
echo "=== ${vector}" |
|||
data_dir="${OUT_DIR}/${vector}" |
|||
rm -rf "${data_dir}" && mkdir -p "${data_dir}" |
|||
|
|||
DATA_FOLDER="${data_dir}" DATABASE_URL="sqlite://${data_dir}/db.sqlite3" \ |
|||
ROCKET_ADDRESS=127.0.0.1 ROCKET_PORT=8099 DOMAIN="${URL}" WEB_VAULT_ENABLED=false \ |
|||
SIGNUPS_ALLOWED=true SIGNUPS_VERIFY=false LOGIN_RATELIMIT_MAX_BURST=1000 \ |
|||
"${VW_DIR}/target/debug/vaultwarden" > "${data_dir}/vaultwarden.log" 2>&1 & |
|||
server=$! |
|||
curl -sf --retry 30 --retry-connrefused --retry-delay 1 "${URL}/alive" > /dev/null |
|||
|
|||
status=0 |
|||
credentials="$(python3 "${VW_DIR}/tools/sdk-live-tests/register_vector.py" "${vector_file}" "${URL}")" || status=$? |
|||
if [[ ${status} -eq 0 ]]; then |
|||
(cd "${TESTS_DIR}" && BW_LIVE_SERVER_URL="${URL}" BW_LIVE_EMAIL="$(sed -n 1p <<< "${credentials}")" \ |
|||
BW_LIVE_PASSWORD="$(sed -n 2p <<< "${credentials}")" npm run test:live) |
|||
elif [[ ${status} -ne ${SKIP} ]]; then |
|||
exit "${status}" |
|||
fi |
|||
|
|||
kill "${server}" && wait "${server}" || true |
|||
done |
|||
echo "All test vectors passed" |
|||
Loading…
Reference in new issue