From cc5271e6dfd47ed9162a39ec9a593388862a47a1 Mon Sep 17 00:00:00 2001 From: drakezhang Date: Tue, 13 Oct 2026 19:14:59 +0800 Subject: [PATCH] feat(protocol): define local pairing authentication --- Cargo.lock | 28 ++ .../src/transport/local-auth-crypto.test.ts | 95 +++++ .../src/transport/local-auth-crypto.ts | 109 ++++++ .../src/transport/local-auth-protocol.ts | 156 +++++++++ apps/extension/src/transport/types.ts | 1 + crates/bsk-cli/src/cli/render_error.rs | 7 + crates/bsk-protocol/Cargo.toml | 3 + crates/bsk-protocol/README.md | 30 ++ .../schema/tool_session_stop_result.json | 3 +- crates/bsk-protocol/src/error.rs | 1 + crates/bsk-protocol/src/lib.rs | 1 + crates/bsk-protocol/src/local_auth.rs | 331 ++++++++++++++++++ .../tests/fixtures/local-auth-v1.json | 21 ++ crates/bsk-protocol/tests/local_auth.rs | 137 ++++++++ 14 files changed, 922 insertions(+), 1 deletion(-) create mode 100644 apps/extension/src/transport/local-auth-crypto.test.ts create mode 100644 apps/extension/src/transport/local-auth-crypto.ts create mode 100644 apps/extension/src/transport/local-auth-protocol.ts create mode 100644 crates/bsk-protocol/src/local_auth.rs create mode 100644 crates/bsk-protocol/tests/fixtures/local-auth-v1.json create mode 100644 crates/bsk-protocol/tests/local_auth.rs diff --git a/Cargo.lock b/Cargo.lock index 28816e493..5a1f7c8ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -178,11 +178,14 @@ dependencies = [ name = "bsk-protocol" version = "0.4.1" dependencies = [ + "base64", + "hmac", "indexmap", "schemars", "semver", "serde", "serde_json", + "sha2", "thiserror 2.0.18", ] @@ -288,6 +291,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -421,6 +430,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -468,6 +486,7 @@ dependencies = [ "block-buffer 0.12.1", "const-oid", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -721,6 +740,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "http" version = "1.4.0" diff --git a/apps/extension/src/transport/local-auth-crypto.test.ts b/apps/extension/src/transport/local-auth-crypto.test.ts new file mode 100644 index 000000000..c2332d789 --- /dev/null +++ b/apps/extension/src/transport/local-auth-crypto.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import v from "../../../../crates/bsk-protocol/tests/fixtures/local-auth-v1.json"; +import type { Purpose } from "./local-auth-crypto"; +import { decodeBytes, encodeBytes, fields, prove, transcript, verify } from "./local-auth-crypto"; +import { parseAuthJson, parseAuthMessage, parseChallenge } from "./local-auth-protocol"; + +describe("local authentication protocol", () => { + it("L22: matches the Rust vectors for every proof and field encoding", async () => { + const t = transcript(v); + expect(encodeBytes(t)).toBe(v.transcript); + expect(encodeBytes(fields([new Uint8Array([1, 2]), new Uint8Array([3])]))).toBe( + "AAAAAgECAAAAAQM", + ); + for (const purpose of ["server", "client"] as const) + expect(await prove(v.key, purpose, t)).toBe(v[purpose]); + const candidate = await prove(v.key, "rotate", t, v.txn); + expect(candidate).toBe(v.candidate); + for (const purpose of ["confirm", "committed"] as const) + expect(await prove(candidate, purpose, t, v.txn)).toBe(v[purpose]); + }); + it("L08b: recovers using the saved candidate and the new transcript", async () => { + const t = transcript({ + ...v, + gen: 1, + client_nonce: v.recovery_client_nonce, + server_nonce: v.recovery_server_nonce, + }); + expect(encodeBytes(t)).toBe(v.recovery_transcript); + expect(await verify(v.candidate, "committed", t, v.commit_proof, v.txn)).toBe(true); + expect( + await verify(await prove(v.key, "rotate", t, v.txn), "committed", t, v.commit_proof, v.txn), + ).toBe(false); + }); + it("L06: rejects reflected proofs across every role", async () => { + const roles: Purpose[] = ["server", "client", "confirm", "committed"]; + for (const role of roles) { + const proof = await prove( + v.key, + role, + transcript(v), + roles.indexOf(role) > 1 ? v.txn : undefined, + ); + for (const other of roles) + expect( + await verify( + v.key, + other, + transcript(v), + proof, + roles.indexOf(other) > 1 ? v.txn : undefined, + ), + ).toBe(role === other); + } + }); + it("L15: validates parsed fields and rejects unknown, oversized and malformed messages", () => { + const valid = JSON.stringify({ type: "local.auth.prove", v: 1, client_proof: v.client }); + expect(parseAuthMessage(valid).type).toBe("local.auth.prove"); + expect(parseAuthMessage(valid.replace('"v":1', '"v":2,"v":1'))).toEqual(JSON.parse(valid)); + for (const text of [ + valid.replace('"v":1', '"v":1,"v":2'), + valid.replace('"v":1', '"v":1,"\\u0076":2'), + valid.replace('"v":1', '"v":1,"extra":true'), + valid.replace('"v":1', '"v":2'), + `${valid}${" ".repeat(4096)}`, + '{"x":[{"x":1,"x":2}]}', + '{"x":1,}', + "[1 2]", + ]) + expect(() => parseAuthMessage(text)).toThrow(); + expect(() => + parseAuthMessage( + JSON.stringify({ + type: "local.auth.ready", + v: 1, + rotation: { txn: v.txn, gen: 1 }, + commit: { txn: v.txn, gen: 1, proof: v.committed }, + }), + ), + ).toThrow(); + expect(() => + parseChallenge({ + type: "pairing_required", + daemon_id: v.daemon_id, + legacy_accepted: true, + extra: 1, + }), + ).toThrow(); + expect(() => decodeBytes(`${v.key}=`, 32)).toThrow(); + expect(() => decodeBytes("a".repeat(43), 32)).toThrow(); + expect(parseAuthJson('{"a":[null,true,false,{"a":1}],"b":"c"}')).toEqual({ + a: [null, true, false, { a: 1 }], + b: "c", + }); + }); +}); diff --git a/apps/extension/src/transport/local-auth-crypto.ts b/apps/extension/src/transport/local-auth-crypto.ts new file mode 100644 index 000000000..72faa5c5e --- /dev/null +++ b/apps/extension/src/transport/local-auth-crypto.ts @@ -0,0 +1,109 @@ +/** Local authentication v1. Keys remain in extension-owned storage. */ +export function encodeBytes(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, ""); +} + +export function decodeBytes(value: string, length: number): Uint8Array { + if (value.length !== Math.ceil((length * 8) / 6) || !/^[A-Za-z0-9_-]+$/.test(value)) { + throw new Error("Invalid authentication field"); + } + const bytes = Uint8Array.from(atob(value.replaceAll("-", "+").replaceAll("_", "/")), (c) => + c.charCodeAt(0), + ); + if (bytes.length !== length || encodeBytes(bytes) !== value) + throw new Error("Invalid authentication field"); + return bytes; +} + +const utf8 = (value: string) => new TextEncoder().encode(value); +export function uint32(value: number): Uint8Array { + if (!Number.isInteger(value) || value < 0 || value > 0xffffffff) + throw new Error("Invalid authentication generation"); + const bytes = new Uint8Array(4); + new DataView(bytes.buffer).setUint32(0, value); + return bytes; +} + +export function fields(parts: Uint8Array[]): Uint8Array { + const result = new Uint8Array(parts.reduce((length, part) => length + 4 + part.length, 0)); + let offset = 0; + for (const part of parts) { + result.set(uint32(part.length), offset); + result.set(part, offset + 4); + offset += 4 + part.length; + } + return result; +} + +export interface Transcript { + daemon_id: string; + device_id: string; + gen: number; + instance_id: string; + origin: string; + client_nonce: string; + server_nonce: string; +} +export function transcript(value: Transcript): Uint8Array { + return fields([ + utf8("bsk-local-auth-v1"), + decodeBytes(value.daemon_id, 16), + decodeBytes(value.device_id, 16), + uint32(value.gen), + utf8(value.instance_id), + utf8(value.origin), + decodeBytes(value.client_nonce, 32), + decodeBytes(value.server_nonce, 32), + ]); +} + +export type Purpose = "server" | "client" | "rotate" | "confirm" | "committed"; +function proofInput(purpose: Purpose, value: Uint8Array, txn?: string): Uint8Array { + if ((purpose === "server" || purpose === "client") !== (txn === undefined)) { + throw new Error("Invalid authentication purpose"); + } + return fields( + txn === undefined ? [utf8(purpose), value] : [utf8(purpose), value, decodeBytes(txn, 16)], + ); +} +function importKey(key: string): Promise { + return crypto.subtle.importKey( + "raw", + decodeBytes(key, 32), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"], + ); +} +export async function prove( + key: string, + purpose: Purpose, + value: Uint8Array, + txn?: string, +): Promise { + return encodeBytes( + new Uint8Array( + await crypto.subtle.sign("HMAC", await importKey(key), proofInput(purpose, value, txn)), + ), + ); +} +export async function verify( + key: string, + purpose: Purpose, + value: Uint8Array, + proof: string, + txn?: string, +): Promise { + return crypto.subtle.verify( + "HMAC", + await importKey(key), + decodeBytes(proof, 32), + proofInput(purpose, value, txn), + ); +} +export function randomBytes(length: 16 | 32): string { + return encodeBytes(crypto.getRandomValues(new Uint8Array(length))); +} diff --git a/apps/extension/src/transport/local-auth-protocol.ts b/apps/extension/src/transport/local-auth-protocol.ts new file mode 100644 index 000000000..52903f71c --- /dev/null +++ b/apps/extension/src/transport/local-auth-protocol.ts @@ -0,0 +1,156 @@ +import { decodeBytes, uint32 } from "./local-auth-crypto"; + +export const MAX_AUTH_MESSAGE_BYTES = 4096; +export interface Rotation { + txn: string; + gen: number; +} +export interface Commit extends Rotation { + proof: string; +} +export interface CredentialOffer { + role: "active" | "staged"; + device_id: string; + gen: number; + pending: Rotation | null; +} +export interface Hello { + v: 1; + client_nonce: string; + instance_id: string; + credentials: CredentialOffer[]; +} +export type ChallengeResponse = + | { + type: "challenge"; + daemon_id: string; + device_id: string; + gen: number; + server_nonce: string; + server_proof: string; + } + | { type: "pairing_required"; daemon_id: string; legacy_accepted: boolean } + | { type: "busy"; retry_after_ms: number }; +export type AuthMessage = + | { type: "local.auth.prove"; v: 1; client_proof: string } + | { type: "local.auth.ready"; v: 1; commit: Commit | null; rotation: Rotation | null } + | { + type: "local.auth.rejected"; + v: 1; + reason: "already_paired" | "replace_mismatch"; + browser_id: string; + } + | { type: "local.auth.rotate_confirm"; v: 1; txn: string; proof: string } + | { type: "local.auth.committed"; v: 1; gen: number; proof: string }; + +function invalid(): never { + throw new Error("Invalid local authentication message"); +} + +/** Schema checks and proof verification consume the same parsed value. */ +export function parseAuthJson(text: string): unknown { + if (new TextEncoder().encode(text).length > MAX_AUTH_MESSAGE_BYTES) invalid(); + try { + return JSON.parse(text); + } catch { + return invalid(); + } +} + +function object(value: unknown, keys: string[]): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) invalid(); + const result = value as Record; + if (Object.keys(result).length !== keys.length || keys.some((key) => !Object.hasOwn(result, key))) + invalid(); + return result; +} +function bytes(value: unknown, length: number): void { + if (typeof value !== "string") invalid(); + decodeBytes(value, length); +} +function generation(value: unknown): void { + if (typeof value !== "number") invalid(); + uint32(value); +} +function rotation(value: unknown, commit = false): void { + const record = object(value, commit ? ["txn", "gen", "proof"] : ["txn", "gen"]); + bytes(record.txn, 16); + generation(record.gen); + if (commit) bytes(record.proof, 32); +} +export function parseChallenge(value: unknown): ChallengeResponse { + if (!value || typeof value !== "object") invalid(); + switch ((value as { type?: unknown }).type) { + case "challenge": { + const record = object(value, [ + "type", + "daemon_id", + "device_id", + "gen", + "server_nonce", + "server_proof", + ]); + bytes(record.daemon_id, 16); + bytes(record.device_id, 16); + generation(record.gen); + bytes(record.server_nonce, 32); + bytes(record.server_proof, 32); + break; + } + case "pairing_required": { + const record = object(value, ["type", "daemon_id", "legacy_accepted"]); + bytes(record.daemon_id, 16); + if (typeof record.legacy_accepted !== "boolean") invalid(); + break; + } + case "busy": + generation(object(value, ["type", "retry_after_ms"]).retry_after_ms); + break; + default: + invalid(); + } + return value as ChallengeResponse; +} +export function parseAuthMessage(text: string): AuthMessage { + const value = parseAuthJson(text); + if (!value || typeof value !== "object") invalid(); + const header = value as { type?: unknown; v?: unknown }; + if (header.v !== 1) invalid(); + switch (header.type) { + case "local.auth.prove": + bytes(object(value, ["type", "v", "client_proof"]).client_proof, 32); + break; + case "local.auth.ready": { + const record = object(value, ["type", "v", "commit", "rotation"]); + if (record.commit !== null && record.rotation !== null) invalid(); + if (record.commit !== null) rotation(record.commit, true); + if (record.rotation !== null) rotation(record.rotation); + break; + } + case "local.auth.rejected": { + const record = object(value, ["type", "v", "reason", "browser_id"]); + if ( + !["already_paired", "replace_mismatch"].includes(String(record.reason)) || + typeof record.browser_id !== "string" || + !/^[\da-fA-F]{8}$/.test(record.browser_id) + ) + invalid(); + break; + } + case "local.auth.rotate_confirm": { + const record = object(value, ["type", "v", "txn", "proof"]); + bytes(record.txn, 16); + bytes(record.proof, 32); + break; + } + case "local.auth.committed": { + const record = object(value, ["type", "v", "gen", "proof"]); + generation(record.gen); + bytes(record.proof, 32); + break; + } + default: + invalid(); + } + return value as AuthMessage; +} diff --git a/apps/extension/src/transport/types.ts b/apps/extension/src/transport/types.ts index 802de108c..c0b9464a4 100644 --- a/apps/extension/src/transport/types.ts +++ b/apps/extension/src/transport/types.ts @@ -19,6 +19,7 @@ export type ErrorCode = | "cancelled" | "user_aborted" | "version_too_old" + | "pairing_required" | "multiple_browsers_online" | "no_browser_connected"; diff --git a/crates/bsk-cli/src/cli/render_error.rs b/crates/bsk-cli/src/cli/render_error.rs index 7188ece38..c751cfc10 100644 --- a/crates/bsk-cli/src/cli/render_error.rs +++ b/crates/bsk-cli/src/cli/render_error.rs @@ -106,6 +106,13 @@ pub fn info_for(code: ErrorCode) -> RenderInfo { ), exit_code: 6, }, + ErrorCode::PairingRequired => RenderInfo { + summary: "local browser pairing is required", + hint: Some( + "run `bsk daemon pair`, then paste the link into the extension's connection settings", + ), + exit_code: 1, + }, ErrorCode::UnknownMethod => RenderInfo { summary: "daemon does not recognise this RPC method", hint: Some( diff --git a/crates/bsk-protocol/Cargo.toml b/crates/bsk-protocol/Cargo.toml index 6aab174cb..539fce085 100644 --- a/crates/bsk-protocol/Cargo.toml +++ b/crates/bsk-protocol/Cargo.toml @@ -20,6 +20,9 @@ semver = { workspace = true } indexmap = { version = "2", features = ["serde"] } schemars = { workspace = true, features = ["indexmap2"] } thiserror = { workspace = true } +base64 = { workspace = true } +sha2 = { workspace = true } +hmac = "0.13" [[bin]] name = "dump-schema" diff --git a/crates/bsk-protocol/README.md b/crates/bsk-protocol/README.md index 88866c098..769b90427 100644 --- a/crates/bsk-protocol/README.md +++ b/crates/bsk-protocol/README.md @@ -13,6 +13,36 @@ refs for iframe/shadow-root targets. `text` compares raw `textContent`, includin Target and attribute limits count Unicode code points. Runtime validation additionally checks field-dependent value types and whether `attribute` is present only for attribute checks. +Local authentication v1 precedes the business handshake. The first frame is a +`system.ping` request whose `params.local_auth` contains a client nonce, instance +ID, and active/staged credential offers (IDs and generations only). The daemon +responds with `challenge`, `pairing_required`, or `busy`. A challenge binds both +nonces, daemon/device IDs, generation, instance ID, and HTTP Origin. The extension +verifies the server HMAC before sending its client HMAC. + +`local.auth.ready` completes ordinary authentication. When it carries `rotation`, +the extension durably saves a candidate key before sending `rotate_confirm`; the +daemon durably commits before replying `committed`. Recovery instead completes +with `ready.commit`, proved using the saved candidate and this connection's +transcript. Only one final message is permitted. Business `system.handshake` +must wait until that final message and all credential writes complete. + +Fields are encoded as repeated `u32_be(length) || bytes`. The transcript is +`F("bsk-local-auth-v1", daemon_id, device_id, u32_be(gen), instance_id, origin, +client_nonce, server_nonce)`. Server/client proofs use HMAC-SHA256 over +`F(role, transcript)`; candidate, confirmation, and commit proofs use +`F(role, transcript, txn)` with roles `rotate`, `confirm`, and `committed`. +Confirmation and commit proofs use the candidate as the HMAC key. + +Authentication frames are limited to 4 KiB, use canonical unpadded base64url, +and reject unknown fields. Senders emit unique field names. Rust deserialization +rejects duplicate fields; the extension uses JSON.parse's last-value semantics, +then applies schema checks and proof verification to that same parsed value. +Shared Rust/Web Crypto vectors are in +`tests/fixtures/local-auth-v1.json`. Errors deliberately omit input values. +Local keys are distinct from remote Bearer grants. Legacy handshakes are governed +by local policy; an unauthenticated peer can never authorize a downgrade. + ```bash cargo run -p bsk-protocol --bin dump-schema --locked ``` diff --git a/crates/bsk-protocol/schema/tool_session_stop_result.json b/crates/bsk-protocol/schema/tool_session_stop_result.json index 7de3f615f..a1d0f8d3e 100644 --- a/crates/bsk-protocol/schema/tool_session_stop_result.json +++ b/crates/bsk-protocol/schema/tool_session_stop_result.json @@ -36,7 +36,8 @@ "user_aborted", "version_too_old", "multiple_browsers_online", - "no_browser_connected" + "no_browser_connected", + "pairing_required" ] }, { diff --git a/crates/bsk-protocol/src/error.rs b/crates/bsk-protocol/src/error.rs index dc2771961..13f69efae 100644 --- a/crates/bsk-protocol/src/error.rs +++ b/crates/bsk-protocol/src/error.rs @@ -33,6 +33,7 @@ pub enum ErrorCode { VersionTooOld, MultipleBrowsersOnline, NoBrowserConnected, + PairingRequired, } #[derive(Debug, Error)] diff --git a/crates/bsk-protocol/src/lib.rs b/crates/bsk-protocol/src/lib.rs index 3da5eb081..4254e69e7 100644 --- a/crates/bsk-protocol/src/lib.rs +++ b/crates/bsk-protocol/src/lib.rs @@ -3,6 +3,7 @@ pub mod cancel; pub mod error; pub mod frame; +pub mod local_auth; pub mod method; pub mod system; pub mod tools; diff --git a/crates/bsk-protocol/src/local_auth.rs b/crates/bsk-protocol/src/local_auth.rs new file mode 100644 index 000000000..aab8726b0 --- /dev/null +++ b/crates/bsk-protocol/src/local_auth.rs @@ -0,0 +1,331 @@ +//! Local WebSocket authentication v1. Secrets never travel on the wire. + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use hmac::{Hmac, KeyInit, Mac}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error}; +use sha2::Sha256; + +use crate::RpcId; + +pub const MAX_MESSAGE_BYTES: usize = 4096; +pub const MAX_CREDENTIALS: usize = 16; + +/// Canonical, unpadded base64url with a fixed decoded length. Debug output is +/// deliberately redacted: the same representation is used for stored keys. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Bytes(pub [u8; N]); + +impl std::fmt::Debug for Bytes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("[redacted]") + } +} +impl Bytes { + pub fn encode(&self) -> String { + URL_SAFE_NO_PAD.encode(self.0) + } + pub fn decode(value: &str) -> Result { + if value.len() != (N * 8).div_ceil(6) { + return Err(InvalidMessage); + } + let bytes = URL_SAFE_NO_PAD.decode(value).map_err(|_| InvalidMessage)?; + let result = Self(bytes.try_into().map_err(|_| InvalidMessage)?); + if result.encode() != value { + return Err(InvalidMessage); + } + Ok(result) + } +} +impl Serialize for Bytes { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.encode()) + } +} +impl<'de, const N: usize> Deserialize<'de> for Bytes { + fn deserialize>(deserializer: D) -> Result { + Self::decode(&String::deserialize(deserializer)?) + .map_err(|_| D::Error::custom("invalid encoded authentication field")) + } +} + +#[derive(Debug, thiserror::Error)] +#[error("invalid local authentication message")] +pub struct InvalidMessage; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "u8", into = "u8")] +pub struct V1; +impl TryFrom for V1 { + type Error = InvalidMessage; + fn try_from(value: u8) -> Result { + if value == 1 { + Ok(Self) + } else { + Err(InvalidMessage) + } + } +} +impl From for u8 { + fn from(_: V1) -> Self { + 1 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CredentialRole { + Active, + Staged, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Rotation { + pub txn: Bytes<16>, + pub r#gen: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Credential { + pub role: CredentialRole, + pub device_id: Bytes<16>, + pub r#gen: u32, + pub pending: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Hello { + pub v: V1, + pub client_nonce: Bytes<32>, + pub instance_id: String, + pub credentials: Vec, +} +impl Hello { + pub fn validate(&self) -> Result<(), InvalidMessage> { + if self.instance_id.len() != 8 + || !self.instance_id.bytes().all(|b| b.is_ascii_hexdigit()) + || self.credentials.len() > MAX_CREDENTIALS + { + return Err(InvalidMessage); + } + let mut ids = std::collections::BTreeSet::new(); + for credential in &self.credentials { + if !ids.insert(credential.device_id) + || credential + .pending + .as_ref() + .is_some_and(|p| credential.r#gen.checked_add(1) != Some(p.r#gen)) + { + return Err(InvalidMessage); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ProbeMethod { + #[serde(rename = "system.ping")] + Ping, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProbeParams { + pub local_auth: Hello, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Probe { + pub id: RpcId, + pub method: ProbeMethod, + pub params: ProbeParams, +} +impl Probe { + pub fn parse(text: &str) -> Result { + let probe: Self = parse(text)?; + probe.params.local_auth.validate()?; + Ok(probe) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum ChallengeResponse { + Challenge { + daemon_id: Bytes<16>, + device_id: Bytes<16>, + r#gen: u32, + server_nonce: Bytes<32>, + server_proof: Bytes<32>, + }, + PairingRequired { + daemon_id: Bytes<16>, + legacy_accepted: bool, + }, + Busy { + retry_after_ms: u32, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Commit { + pub txn: Bytes<16>, + pub r#gen: u32, + pub proof: Bytes<32>, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RejectionReason { + AlreadyPaired, + ReplaceMismatch, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", deny_unknown_fields)] +pub enum AuthMessage { + #[serde(rename = "local.auth.prove")] + Prove { v: V1, client_proof: Bytes<32> }, + #[serde(rename = "local.auth.ready")] + Ready { + v: V1, + commit: Option, + rotation: Option, + }, + #[serde(rename = "local.auth.rejected")] + Rejected { + v: V1, + reason: RejectionReason, + browser_id: String, + }, + #[serde(rename = "local.auth.rotate_confirm")] + RotateConfirm { + v: V1, + txn: Bytes<16>, + proof: Bytes<32>, + }, + #[serde(rename = "local.auth.committed")] + Committed { v: V1, r#gen: u32, proof: Bytes<32> }, +} +impl AuthMessage { + pub fn parse(text: &str) -> Result { + let message: Self = parse(text)?; + if matches!( + &message, + Self::Ready { + commit: Some(_), + rotation: Some(_), + .. + } + ) { + return Err(InvalidMessage); + } + Ok(message) + } +} +fn parse(text: &str) -> Result { + if text.len() > MAX_MESSAGE_BYTES { + return Err(InvalidMessage); + } + // Parse the original text, never an intermediate Value: serde then rejects + // duplicate fields instead of silently retaining the last occurrence. + serde_json::from_str(text).map_err(|_| InvalidMessage) +} + +pub fn fields(parts: &[&[u8]]) -> Vec { + let mut result = Vec::new(); + for part in parts { + result.extend_from_slice( + &u32::try_from(part.len()) + .expect("bounded field") + .to_be_bytes(), + ); + result.extend_from_slice(part); + } + result +} + +pub struct Transcript<'a> { + pub daemon_id: &'a Bytes<16>, + pub device_id: &'a Bytes<16>, + pub r#gen: u32, + pub instance_id: &'a str, + pub origin: &'a str, + pub client_nonce: &'a Bytes<32>, + pub server_nonce: &'a Bytes<32>, +} +impl Transcript<'_> { + pub fn encode(&self) -> Vec { + fields(&[ + b"bsk-local-auth-v1", + &self.daemon_id.0, + &self.device_id.0, + &self.r#gen.to_be_bytes(), + self.instance_id.as_bytes(), + self.origin.as_bytes(), + &self.client_nonce.0, + &self.server_nonce.0, + ]) + } +} + +#[derive(Clone, Copy)] +pub enum Purpose { + Server, + Client, + Rotate, + Confirm, + Committed, +} +impl Purpose { + fn label(self) -> &'static [u8] { + match self { + Self::Server => b"server", + Self::Client => b"client", + Self::Rotate => b"rotate", + Self::Confirm => b"confirm", + Self::Committed => b"committed", + } + } +} + +fn mac( + key: &Bytes<32>, + purpose: Purpose, + transcript: &[u8], + txn: Option<&Bytes<16>>, +) -> Hmac { + let mut mac = Hmac::::new_from_slice(&key.0).expect("32-byte HMAC key"); + let data = match txn { + Some(txn) => fields(&[purpose.label(), transcript, &txn.0]), + None => fields(&[purpose.label(), transcript]), + }; + mac.update(&data); + mac +} +pub fn prove( + key: &Bytes<32>, + purpose: Purpose, + transcript: &[u8], + txn: Option<&Bytes<16>>, +) -> Bytes<32> { + Bytes( + mac(key, purpose, transcript, txn) + .finalize() + .into_bytes() + .into(), + ) +} +pub fn verify( + key: &Bytes<32>, + purpose: Purpose, + transcript: &[u8], + txn: Option<&Bytes<16>>, + proof: &Bytes<32>, +) -> bool { + mac(key, purpose, transcript, txn) + .verify_slice(&proof.0) + .is_ok() +} diff --git a/crates/bsk-protocol/tests/fixtures/local-auth-v1.json b/crates/bsk-protocol/tests/fixtures/local-auth-v1.json new file mode 100644 index 000000000..0c068006e --- /dev/null +++ b/crates/bsk-protocol/tests/fixtures/local-auth-v1.json @@ -0,0 +1,21 @@ +{ + "key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8", + "daemon_id": "AAECAwQFBgcICQoLDA0ODw", + "device_id": "EBESExQVFhcYGRobHB0eHw", + "gen": 0, + "instance_id": "a1b2c3d4", + "origin": "chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "client_nonce": "ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8", + "server_nonce": "QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl8", + "txn": "YGFiY2RlZmdoaWprbG1ubw", + "transcript": "AAAAEWJzay1sb2NhbC1hdXRoLXYxAAAAEAABAgMEBQYHCAkKCwwNDg8AAAAQEBESExQVFhcYGRobHB0eHwAAAAQAAAAAAAAACGExYjJjM2Q0AAAAM2Nocm9tZS1leHRlbnNpb246Ly9hYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYQAAACAgISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0-PwAAACBAQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eXw", + "server": "3kYnJZkBJNxjMV9W8xHy6a6Y4BXfMVvmpOJNNA-Fduc", + "client": "7Yb1YDdWz8NLZLxGgjR6jhQXQm-LnTd7WJl_dS4S_fc", + "candidate": "bktCCmdc7MYtPJuC6SqH5CxZK3HymCtg365sQuyI_bw", + "confirm": "uHzjJtpS8eNBG5Dc6s1xDCB5x-wQv2US1D5AkwjGnLY", + "committed": "m3i9eT_M1bN4pUaS8Fqo0amjiTNfGSNER61C9mZzy3I", + "recovery_client_nonce": "gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp8", + "recovery_server_nonce": "oKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr8", + "recovery_transcript": "AAAAEWJzay1sb2NhbC1hdXRoLXYxAAAAEAABAgMEBQYHCAkKCwwNDg8AAAAQEBESExQVFhcYGRobHB0eHwAAAAQAAAABAAAACGExYjJjM2Q0AAAAM2Nocm9tZS1leHRlbnNpb246Ly9hYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYQAAACCAgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2enwAAACCgoaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2-vw", + "commit_proof": "UHRwHwjqBrBciRFE90GLUCEauQWDYxiWCzEhDyA7o9o" +} diff --git a/crates/bsk-protocol/tests/local_auth.rs b/crates/bsk-protocol/tests/local_auth.rs new file mode 100644 index 000000000..dea060d8c --- /dev/null +++ b/crates/bsk-protocol/tests/local_auth.rs @@ -0,0 +1,137 @@ +use bsk_protocol::local_auth::*; +use serde_json::{Value, json}; + +fn vector() -> Value { + serde_json::from_str(include_str!("fixtures/local-auth-v1.json")).unwrap() +} +fn bytes(v: &Value, name: &str) -> Bytes { + Bytes::decode(v[name].as_str().unwrap()).unwrap() +} +fn transcript(v: &Value, recovery: bool) -> Vec { + Transcript { + daemon_id: &bytes(v, "daemon_id"), + device_id: &bytes(v, "device_id"), + r#gen: u32::from(recovery), + instance_id: v["instance_id"].as_str().unwrap(), + origin: v["origin"].as_str().unwrap(), + client_nonce: &bytes( + v, + if recovery { + "recovery_client_nonce" + } else { + "client_nonce" + }, + ), + server_nonce: &bytes( + v, + if recovery { + "recovery_server_nonce" + } else { + "server_nonce" + }, + ), + } + .encode() +} + +#[test] +fn l22_shared_vectors_and_l08b_saved_candidate_recovery() { + let v = vector(); + let t = transcript(&v, false); + let key = bytes(&v, "key"); + let txn = bytes(&v, "txn"); + for (purpose, name) in [(Purpose::Server, "server"), (Purpose::Client, "client")] { + assert_eq!(prove(&key, purpose, &t, None), bytes(&v, name)); + } + let candidate = prove(&key, Purpose::Rotate, &t, Some(&txn)); + assert_eq!(candidate, bytes(&v, "candidate")); + for (purpose, name) in [ + (Purpose::Confirm, "confirm"), + (Purpose::Committed, "committed"), + ] { + assert_eq!(prove(&candidate, purpose, &t, Some(&txn)), bytes(&v, name)); + } + let recovery = transcript(&v, true); + assert_eq!( + prove(&candidate, Purpose::Committed, &recovery, Some(&txn)), + bytes(&v, "commit_proof") + ); + let rederived = prove(&key, Purpose::Rotate, &recovery, Some(&txn)); + assert!(!verify( + &rederived, + Purpose::Committed, + &recovery, + Some(&txn), + &bytes(&v, "commit_proof") + )); +} + +#[test] +fn l06_proofs_cannot_be_reflected_or_replayed() { + let v = vector(); + let t = transcript(&v, false); + let key = bytes(&v, "key"); + let txn = bytes(&v, "txn"); + let purposes = [ + Purpose::Server, + Purpose::Client, + Purpose::Confirm, + Purpose::Committed, + ]; + for (i, purpose) in purposes.iter().enumerate() { + let transaction = (i > 1).then_some(&txn); + let proof = prove(&key, *purpose, &t, transaction); + assert!(verify(&key, *purpose, &t, transaction, &proof)); + for (j, other) in purposes.iter().enumerate() { + if i != j { + assert!(!verify(&key, *other, &t, (j > 1).then_some(&txn), &proof)); + } + } + assert!(!verify( + &key, + *purpose, + &transcript(&v, true), + transaction, + &proof + )); + } +} + +#[test] +fn l15_rejects_duplicate_unknown_oversized_and_invalid_fields() { + let v = vector(); + let valid = json!({"type":"local.auth.prove", "v":1, "client_proof": v["client"]}).to_string(); + assert!(AuthMessage::parse(&valid).is_ok()); + for text in [ + valid.replace("\"v\":1", "\"v\":1,\"v\":1"), + valid.replace("\"v\":1", "\"v\":1,\"extra\":true"), + valid.replace("\"v\":1", "\"v\":2"), + format!("{valid}{}", " ".repeat(MAX_MESSAGE_BYTES)), + ] { + assert!(AuthMessage::parse(&text).is_err()); + } + let pending = json!({"gen":1,"txn":v["txn"]}); + let ready = json!({"type":"local.auth.ready","v":1,"rotation":pending,"commit":{"gen":1,"txn":v["txn"],"proof":v["committed"]}}); + assert!(AuthMessage::parse(&ready.to_string()).is_err()); + for bad in [ + format!("{}=", v["key"].as_str().unwrap()), + "a".repeat(43), + "!".repeat(43), + ] { + assert!(Bytes::<32>::decode(&bad).is_err()); + } + assert_eq!(format!("{:?}", bytes::<32>(&v, "key")), "[redacted]"); +} + +#[test] +fn l15_probe_limits_apply_before_any_business_handshake() { + let v = vector(); + let mut probe = json!({"id":"probe", "method":"system.ping", "params":{"local_auth":{"v":1,"client_nonce":v["client_nonce"],"instance_id":"a1b2c3d4","credentials":[]}}}); + assert!(Probe::parse(&probe.to_string()).is_ok()); + let credential = json!({"role":"active","device_id":v["device_id"],"gen":0,"pending":null}); + probe["params"]["local_auth"]["credentials"] = json!([credential.clone(), credential]); + assert!(Probe::parse(&probe.to_string()).is_err()); + probe["params"]["local_auth"]["credentials"] = json!([]); + probe["params"]["local_auth"]["extra"] = json!(true); + assert!(Probe::parse(&probe.to_string()).is_err()); +}