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