Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

95 changes: 95 additions & 0 deletions apps/extension/src/transport/local-auth-crypto.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
109 changes: 109 additions & 0 deletions apps/extension/src/transport/local-auth-crypto.ts
Original file line number Diff line number Diff line change
@@ -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<ArrayBuffer> {
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<ArrayBuffer> {
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<ArrayBuffer> {
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<ArrayBuffer> {
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<ArrayBuffer> {
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<CryptoKey> {
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<string> {
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<boolean> {
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)));
}
Loading
Loading