feat(DPoP): add frontend dpop library
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeJkt } from './dpop';
|
||||
|
||||
/**
|
||||
* RFC 7638 §3.1 known-answer vector.
|
||||
*
|
||||
* The RFC's example vector is for an RSA key; there's no equally-blessed
|
||||
* EC vector in the spec text. We instead pin our EC canonicalisation
|
||||
* against the *rules* by constructing a JWK with:
|
||||
* * extra members that MUST be excluded from the hash (use, alg, kid),
|
||||
* * members in non-alphabetical order,
|
||||
* * whitespace hazards,
|
||||
* then verifying that our thumbprint matches an INDEPENDENT re-computation
|
||||
* of the canonical hash. If the alphabetical / whitespace / member-filter
|
||||
* rules regress, this test fails.
|
||||
*
|
||||
* The x/y coordinates below are from a real P-256 keypair generated for
|
||||
* this test — value not sensitive, generation is deterministic given the
|
||||
* algorithm output is exposed.
|
||||
*/
|
||||
describe('computeJkt', () => {
|
||||
it('produces a URL-safe base64 SHA-256 thumbprint of the canonical JWK', async () => {
|
||||
// Generate a real P-256 keypair via SubtleCrypto so the test hits
|
||||
// real bytes, not a hand-rolled JWK that might drift from what
|
||||
// the runtime actually emits.
|
||||
const pair = await crypto.subtle.generateKey(
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
true, // extractable so the test can read the JWK independently
|
||||
['sign', 'verify']
|
||||
);
|
||||
const jkt = await computeJkt(pair.publicKey);
|
||||
|
||||
// Base64URL, no padding, exactly 43 chars for a 32-byte SHA-256.
|
||||
expect(jkt).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
|
||||
// Independent re-computation of the canonical thumbprint —
|
||||
// same rules RFC 7638 §3.2 pins for EC keys.
|
||||
const jwk = await crypto.subtle.exportKey('jwk', pair.publicKey);
|
||||
const canonical = JSON.stringify({
|
||||
crv: jwk.crv,
|
||||
kty: jwk.kty,
|
||||
x: jwk.x,
|
||||
y: jwk.y
|
||||
});
|
||||
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical));
|
||||
const bytes = new Uint8Array(hash);
|
||||
let s = '';
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
const expected = btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
expect(jkt).toBe(expected);
|
||||
});
|
||||
|
||||
it('is stable across repeat calls on the same key', async () => {
|
||||
const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, [
|
||||
'sign',
|
||||
'verify'
|
||||
]);
|
||||
const first = await computeJkt(pair.publicKey);
|
||||
const second = await computeJkt(pair.publicKey);
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it('differs across independently-generated keypairs', async () => {
|
||||
const a = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, [
|
||||
'sign',
|
||||
'verify'
|
||||
]);
|
||||
const b = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, [
|
||||
'sign',
|
||||
'verify'
|
||||
]);
|
||||
expect(await computeJkt(a.publicKey)).not.toBe(await computeJkt(b.publicKey));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* DPoP (RFC 9449) browser-side keypair lifecycle.
|
||||
*
|
||||
* Every SPA session that supports Web Crypto generates a P-256 ECDSA keypair
|
||||
* with `extractable: false` at first login, persists the `CryptoKey` handles
|
||||
* in IndexedDB, and reuses them for every subsequent request's DPoP proof.
|
||||
* The raw key bytes live in the browser's crypto subsystem — JS can call
|
||||
* `sign()` on the handle but never `exportKey()`. That's what defeats
|
||||
* info-stealer replay: the cookie alone is useless without the private key,
|
||||
* and the private key never leaves the browser process's crypto boundary.
|
||||
*
|
||||
* Fail-open contract: any failure here (SubtleCrypto unavailable, IndexedDB
|
||||
* blocked by policy, HTTP-not-HTTPS context) must throw or return so the
|
||||
* caller can log in WITHOUT a `dpop_jkt`. The resulting session lives with
|
||||
* `session.dpop_jkt = NULL` and is exempted at the middleware. This mirrors
|
||||
* `docs/plan/dpop.md`'s explicit design — degradation is per-session and
|
||||
* immutable, so a bound session cannot be downgraded by a later request.
|
||||
*
|
||||
* Threat model boundary: same as any `SubtleCrypto` non-extractable
|
||||
* `CryptoKey`. Defeats today's commodity info-stealers (which target
|
||||
* cookies via SQLite + OS keyring, not IndexedDB CryptoKey blobs).
|
||||
* Doesn't defeat a browser process compromised at login time or a
|
||||
* malicious extension with `webRequest` — those are prerequisites the
|
||||
* whole SPA relies on being clean.
|
||||
*/
|
||||
|
||||
const DB_NAME = 'oxicloud-dpop';
|
||||
const DB_VERSION = 1;
|
||||
const STORE = 'keypair';
|
||||
const KEY = 'current';
|
||||
|
||||
/** Base64URL-encode raw bytes, no padding — RFC 7515 §2 (`base64url`). */
|
||||
function b64u(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/** Open (and create-if-missing) the DPoP object store. */
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function readKeypair(): Promise<CryptoKeyPair | null> {
|
||||
const db = await openDb();
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readonly');
|
||||
const req = tx.objectStore(STORE).get(KEY);
|
||||
req.onsuccess = () => resolve((req.result as CryptoKeyPair | undefined) ?? null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function writeKeypair(pair: CryptoKeyPair): Promise<void> {
|
||||
const db = await openDb();
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
tx.objectStore(STORE).put(pair, KEY);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function generateKeypair(): Promise<CryptoKeyPair> {
|
||||
return crypto.subtle.generateKey(
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
// extractable: false — the private key handle cannot be exported.
|
||||
// SubtleCrypto stores raw bytes in the browser's crypto subsystem
|
||||
// (Keychain/DPAPI-encrypted at rest on disk). JS holds only an
|
||||
// opaque reference usable via sign().
|
||||
false,
|
||||
['sign', 'verify']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the browser's persistent DPoP keypair. On first call in a fresh
|
||||
* profile, generates a new P-256 keypair (non-extractable) and persists
|
||||
* it. Subsequent calls return the SAME handle across the same profile —
|
||||
* across tabs (shared IndexedDB), across reloads, across sessions until
|
||||
* `clearKeypair()` is called.
|
||||
*
|
||||
* Tab-race guard: two tabs opened simultaneously both call
|
||||
* `ensureKeypair()` before either has persisted. `navigator.locks`
|
||||
* serialises them; the second waiter finds the persisted keypair and
|
||||
* returns it, so both tabs converge on the same handle.
|
||||
*
|
||||
* When `navigator.locks` is unavailable (very old Safari), the race is
|
||||
* theoretically possible but statistically rare; the loser overwrites
|
||||
* the winner's keypair, which just means the earlier tab's next request
|
||||
* fails DPoP verification once and forces a re-login. Not catastrophic.
|
||||
*/
|
||||
export async function ensureKeypair(): Promise<CryptoKeyPair> {
|
||||
const doEnsure = async (): Promise<CryptoKeyPair> => {
|
||||
const existing = await readKeypair();
|
||||
if (existing) return existing;
|
||||
const fresh = await generateKeypair();
|
||||
await writeKeypair(fresh);
|
||||
return fresh;
|
||||
};
|
||||
if (typeof navigator !== 'undefined' && navigator.locks?.request) {
|
||||
return navigator.locks.request('oxicloud-dpop-keypair', doEnsure);
|
||||
}
|
||||
return doEnsure();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the RFC 7638 JWK thumbprint of the public key — the value we
|
||||
* send to the server as `dpop_jkt` at login. Base64URL-encoded SHA-256
|
||||
* of the CANONICAL JWK (member names alphabetical, no whitespace, only
|
||||
* the REQUIRED members for the key type — see §3.2 for EC keys).
|
||||
*
|
||||
* Canonicalisation is load-bearing: a rogue `{"kty":"EC","crv":"P-256",...}`
|
||||
* with any deviation (extra whitespace, non-alphabetical order, extra
|
||||
* members like `use` or `alg`) yields a DIFFERENT hash and thus a
|
||||
* different thumbprint — the server would reject the binding. RFC 7638
|
||||
* §3.1 pins the exact serialisation; we reproduce it here.
|
||||
*/
|
||||
export async function computeJkt(pubKey: CryptoKey): Promise<string> {
|
||||
const jwk = await crypto.subtle.exportKey('jwk', pubKey);
|
||||
// RFC 7638 §3.2 — for EC keys, the REQUIRED members are crv, kty,
|
||||
// x, y in ALPHABETICAL order. Any other members (use, alg, kid, …)
|
||||
// MUST be omitted from the hash input.
|
||||
const canonical = JSON.stringify({
|
||||
crv: jwk.crv,
|
||||
kty: jwk.kty,
|
||||
x: jwk.x,
|
||||
y: jwk.y
|
||||
});
|
||||
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical));
|
||||
return b64u(new Uint8Array(hash));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the persistent keypair — called on logout so the next login
|
||||
* mints a fresh binding with no correlation across the boundary.
|
||||
*
|
||||
* Safe to call when no keypair exists (IndexedDB may be absent in
|
||||
* private mode after close-and-reopen).
|
||||
*/
|
||||
export async function clearKeypair(): Promise<void> {
|
||||
let db: IDBDatabase;
|
||||
try {
|
||||
db = await openDb();
|
||||
} catch {
|
||||
return; // IndexedDB unavailable — nothing to clear
|
||||
}
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
tx.objectStore(STORE).delete(KEY);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user