committed by
GitHub
20 changed files with 376 additions and 9 deletions
@ -0,0 +1 @@ |
|||||
|
ALTER TABLE users DROP COLUMN uses_key_connector; |
||||
@ -0,0 +1 @@ |
|||||
|
ALTER TABLE users ADD COLUMN uses_key_connector BOOLEAN NOT NULL DEFAULT FALSE; |
||||
@ -0,0 +1 @@ |
|||||
|
ALTER TABLE users DROP COLUMN uses_key_connector; |
||||
@ -0,0 +1 @@ |
|||||
|
ALTER TABLE users ADD COLUMN uses_key_connector BOOLEAN NOT NULL DEFAULT FALSE; |
||||
@ -0,0 +1 @@ |
|||||
|
ALTER TABLE users DROP COLUMN uses_key_connector; |
||||
@ -0,0 +1 @@ |
|||||
|
ALTER TABLE users ADD COLUMN uses_key_connector BOOLEAN NOT NULL DEFAULT 0; |
||||
@ -0,0 +1,72 @@ |
|||||
|
import { createServer } from 'node:http'; |
||||
|
|
||||
|
/** |
||||
|
* Barebone in-memory key connector, just enough for the clients to enroll and unlock. |
||||
|
* Keys are stored per token subject; token validation is deliberately out of scope, |
||||
|
* this only exists to test the Vaultwarden and web client side of the flow. |
||||
|
*/ |
||||
|
export function startMockKeyConnector(port: number) { |
||||
|
const keys = new Map<string, string>(); |
||||
|
|
||||
|
const server = createServer((req, res) => { |
||||
|
// The web client calls us cross-origin, with credentials
|
||||
|
res.setHeader('Access-Control-Allow-Origin', req.headers['origin'] ?? '*'); |
||||
|
res.setHeader('Access-Control-Allow-Credentials', 'true'); |
||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); |
||||
|
res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers'] ?? '*'); |
||||
|
|
||||
|
if (req.method === 'OPTIONS') { |
||||
|
res.writeHead(204).end(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (req.url === '/alive') { |
||||
|
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({})); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const sub = tokenSubject(req.headers['authorization']); |
||||
|
if (!sub) { |
||||
|
res.writeHead(401).end(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (req.url !== '/user-keys') { |
||||
|
res.writeHead(404).end(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (req.method === 'GET') { |
||||
|
if (!keys.has(sub)) { |
||||
|
res.writeHead(404).end(); |
||||
|
return; |
||||
|
} |
||||
|
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ key: keys.get(sub) })); |
||||
|
} else if (req.method === 'POST') { |
||||
|
let body = ''; |
||||
|
req.on('data', (chunk) => body += chunk); |
||||
|
req.on('end', () => { |
||||
|
keys.set(sub, JSON.parse(body).key); |
||||
|
res.writeHead(200).end(); |
||||
|
}); |
||||
|
} else { |
||||
|
res.writeHead(405).end(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
server.listen(port); |
||||
|
console.log(`Mock key connector running on port ${port}`); |
||||
|
|
||||
|
return { |
||||
|
keys, |
||||
|
stop: () => server.close(), |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
function tokenSubject(header?: string): string | null { |
||||
|
try { |
||||
|
return JSON.parse(Buffer.from(header.replace(/^Bearer /, '').split('.')[1], 'base64url').toString()).sub; |
||||
|
} catch { |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,61 @@ |
|||||
|
import { test, expect, type Page, type TestInfo } from '@playwright/test'; |
||||
|
|
||||
|
import { startMockKeyConnector } from './setups/keyconnector'; |
||||
|
import * as utils from "../global-utils"; |
||||
|
|
||||
|
let users = utils.loadEnv(); |
||||
|
let keyConnector; |
||||
|
|
||||
|
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { |
||||
|
keyConnector = startMockKeyConnector(Number(process.env.KEY_CONNECTOR_PORT)); |
||||
|
await utils.startVault(browser, testInfo, { |
||||
|
SSO_ENABLED: true, |
||||
|
SSO_ONLY: false, |
||||
|
KEY_CONNECTOR_ENABLED: true, |
||||
|
KEY_CONNECTOR_URL: process.env.KEY_CONNECTOR_URL, |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
test.afterAll('Teardown', async ({}) => { |
||||
|
utils.stopVault(); |
||||
|
keyConnector.stop(); |
||||
|
}); |
||||
|
|
||||
|
async function ssoLogin(page: Page, user: { email: string, name: string, password: string }) { |
||||
|
await page.context().clearCookies(); |
||||
|
await utils.cleanLanding(page); |
||||
|
|
||||
|
await page.locator("input[type=email].vw-email-sso").fill(user.email); |
||||
|
await page.getByRole('button', { name: /Use single sign-on/ }).click(); |
||||
|
|
||||
|
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); |
||||
|
await page.getByLabel(/Username/).fill(user.name); |
||||
|
await page.getByLabel('Password', { exact: true }).fill(user.password); |
||||
|
await page.getByRole('button', { name: 'Sign In' }).click(); |
||||
|
} |
||||
|
|
||||
|
test('Account creation using SSO enrolls with the key connector', async ({ page }) => { |
||||
|
await ssoLogin(page, users.user1); |
||||
|
|
||||
|
// Instead of the master password creation we land on the domain confirmation
|
||||
|
await expect(page.getByText(process.env.KEY_CONNECTOR_URL)).toBeVisible(); |
||||
|
// Button label differs between web-vault versions
|
||||
|
await page.getByRole('button', { name: /^(Confirm|Continue with log in)$/ }).click(); |
||||
|
|
||||
|
await utils.ignoreExtension(page); |
||||
|
|
||||
|
await expect(page).toHaveTitle(/Vaultwarden Web/); |
||||
|
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); |
||||
|
|
||||
|
expect(keyConnector.keys.size).toBe(1); |
||||
|
}); |
||||
|
|
||||
|
test('SSO login unlocks with the key from the connector', async ({ page }) => { |
||||
|
await ssoLogin(page, users.user1); |
||||
|
|
||||
|
// No master password prompt, the vault opens directly
|
||||
|
await utils.ignoreExtension(page); |
||||
|
|
||||
|
await expect(page).toHaveTitle(/Vaultwarden Web/); |
||||
|
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); |
||||
|
}); |
||||
@ -0,0 +1,117 @@ |
|||||
|
use rocket::Route; |
||||
|
use rocket::serde::json::Json; |
||||
|
use serde_json::Value; |
||||
|
|
||||
|
use crate::{ |
||||
|
CONFIG, |
||||
|
api::{EmptyResult, JsonResult}, |
||||
|
auth::Headers, |
||||
|
db::{ |
||||
|
DbConn, |
||||
|
models::{Membership, MembershipType, UserId}, |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
pub fn routes() -> Vec<Route> { |
||||
|
routes![post_set_key_connector_key, post_convert_to_key_connector, get_confirmation_details,] |
||||
|
} |
||||
|
|
||||
|
#[derive(Debug, serde::Deserialize)] |
||||
|
#[serde(rename_all = "camelCase")] |
||||
|
pub struct KeyPairData { |
||||
|
encrypted_private_key: String, |
||||
|
public_key: String, |
||||
|
} |
||||
|
|
||||
|
#[derive(Debug, serde::Deserialize)] |
||||
|
#[serde(rename_all = "camelCase")] |
||||
|
pub struct SetKeyConnectorKeyData { |
||||
|
key: String, |
||||
|
keys: KeyPairData, |
||||
|
kdf: i32, |
||||
|
kdf_iterations: i32, |
||||
|
kdf_memory: Option<i32>, |
||||
|
kdf_parallelism: Option<i32>, |
||||
|
#[allow(dead_code)] |
||||
|
org_identifier: String, |
||||
|
} |
||||
|
|
||||
|
async fn can_use_key_connector(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { |
||||
|
if Membership::find_by_user(user_uuid, conn).await.iter().any(|m| m.atype >= MembershipType::Admin) { |
||||
|
err!("Owners and admins cannot use Key Connector and must keep a master password"); |
||||
|
} |
||||
|
Ok(()) |
||||
|
} |
||||
|
|
||||
|
// Called by the client to finish provisioning a new SSO user whose master key
|
||||
|
// was just stored on the key connector.
|
||||
|
#[post("/accounts/set-key-connector-key", data = "<data>")] |
||||
|
async fn post_set_key_connector_key(data: Json<SetKeyConnectorKeyData>, headers: Headers, conn: DbConn) -> EmptyResult { |
||||
|
if !CONFIG.key_connector_enabled() { |
||||
|
err!("Key Connector is not enabled on this server"); |
||||
|
} |
||||
|
|
||||
|
let data = data.into_inner(); |
||||
|
let mut user = headers.user; |
||||
|
|
||||
|
if user.private_key.is_some() { |
||||
|
err!("Account already initialized, cannot set Key Connector key"); |
||||
|
} |
||||
|
|
||||
|
can_use_key_connector(&user.uuid, &conn).await?; |
||||
|
|
||||
|
user.client_kdf_type = data.kdf; |
||||
|
user.client_kdf_iter = data.kdf_iterations; |
||||
|
user.client_kdf_memory = data.kdf_memory; |
||||
|
user.client_kdf_parallelism = data.kdf_parallelism; |
||||
|
|
||||
|
user.akey = data.key; |
||||
|
user.private_key = Some(data.keys.encrypted_private_key); |
||||
|
user.public_key = Some(data.keys.public_key); |
||||
|
|
||||
|
// Key connector users don't have a master password
|
||||
|
user.password_hash = Vec::new(); |
||||
|
user.uses_key_connector = true; |
||||
|
|
||||
|
user.save(&conn).await |
||||
|
} |
||||
|
|
||||
|
// Migrates an existing password user to the key connector. The client has already
|
||||
|
// uploaded the current master key to the connector at this point.
|
||||
|
#[post("/accounts/convert-to-key-connector")] |
||||
|
async fn post_convert_to_key_connector(headers: Headers, conn: DbConn) -> EmptyResult { |
||||
|
if !CONFIG.key_connector_enabled() { |
||||
|
err!("Key Connector is not enabled on this server"); |
||||
|
} |
||||
|
|
||||
|
let mut user = headers.user; |
||||
|
|
||||
|
if user.private_key.is_none() { |
||||
|
err!("Account is not initialized, cannot convert to Key Connector"); |
||||
|
} |
||||
|
|
||||
|
can_use_key_connector(&user.uuid, &conn).await?; |
||||
|
|
||||
|
user.password_hash = Vec::new(); |
||||
|
user.password_hint = None; |
||||
|
user.uses_key_connector = true; |
||||
|
|
||||
|
user.save(&conn).await |
||||
|
} |
||||
|
|
||||
|
#[get("/accounts/key-connector/confirmation-details/<_org_identifier>")] |
||||
|
fn get_confirmation_details(_org_identifier: &str, _headers: Headers) -> JsonResult { |
||||
|
if !CONFIG.key_connector_enabled() { |
||||
|
err!("Key Connector is not enabled on this server"); |
||||
|
} |
||||
|
|
||||
|
// SSO (and therefore the key connector) is global, so there is no real org to look up
|
||||
|
Ok(Json(serde_json::json!({ |
||||
|
"OrganizationName": CONFIG.key_connector_org_name(), |
||||
|
"Object": "keyConnectorUserDecryptionOptionConfirmationDetails" |
||||
|
}))) |
||||
|
} |
||||
|
|
||||
|
pub fn key_connector_user_decryption_option() -> Value { |
||||
|
serde_json::json!({ "KeyConnectorUrl": CONFIG.key_connector_url() }) |
||||
|
} |
||||
Loading…
Reference in new issue