import { describe, expect, it } from 'vitest' import { webcrypto } from 'node:crypto' import { decodeBase64UrlBytes, decodeBase64UrlString } from 'relay-contracts' import { createDpopKey, encodeDpopSubprotocol, extractDpopFromSubprotocols, htuFor, DPOP_HTM, DPOP_SUBPROTOCOL_PREFIX, } from '../src/dpop' // jsdom's global crypto lacks a full SubtleCrypto/Ed25519; inject Node's webcrypto (DI seam). const subtle = webcrypto.subtle as unknown as SubtleCrypto /** Copy into a fresh ArrayBuffer-backed view (WebCrypto's BufferSource wants Uint8Array). */ const ab = (u: Uint8Array): Uint8Array => { const out = new Uint8Array(u.byteLength) out.set(u) return out } const utf8 = (s: string): Uint8Array => ab(new TextEncoder().encode(s)) const bytes = (b64u: string): Uint8Array => ab(decodeBase64UrlBytes(b64u)) const decodeJson = (b64u: string): unknown => JSON.parse(new TextDecoder().decode(decodeBase64UrlBytes(b64u))) describe('createDpopKey (B6) — browser DPoP proof-of-possession', () => { it('produces a 43-char base64url jkt (matches the server JKT_RE)', async () => { const key = await createDpopKey({ subtle }) expect(key.jkt).toMatch(/^[A-Za-z0-9_-]{43}$/) }) it("jkt equals base64url(SHA-256(canonical JWK)) with member order crv,kty,x", async () => { let jti = 0 const key = await createDpopKey({ subtle, randomJti: () => `jti-${jti++}` }) const proof = await key.proof('https://alice/ws', DPOP_HTM, 1000) const [h] = proof.split('.') as [string, string, string] const header = decodeJson(h) as { jwk: { crv: string; kty: string; x: string } } // Recompute the thumbprint from the proof's embedded JWK exactly as relay-auth does. const canonical = JSON.stringify({ crv: 'Ed25519', kty: 'OKP', x: header.jwk.x }) const digest = new Uint8Array(await subtle.digest('SHA-256', utf8(canonical))) const recomputed = Buffer.from(digest).toString('base64url') expect(recomputed).toBe(key.jkt) }) it('builds a 3-part DPoP JWS whose header/payload match relay-auth buildDpopProof', async () => { const key = await createDpopKey({ subtle, randomJti: () => 'fixed-jti' }) const proof = await key.proof('https://alice/ws', 'GET', 1234) const parts = proof.split('.') expect(parts).toHaveLength(3) const [h, p] = parts as [string, string, string] const header = decodeJson(h) as { typ: string; jwk: { crv: string; kty: string; x: string } } const payload = decodeJson(p) as Record expect(header.typ).toBe('dpop+ed25519') expect(header.jwk.kty).toBe('OKP') expect(header.jwk.crv).toBe('Ed25519') expect(payload).toEqual({ htu: 'https://alice/ws', htm: 'GET', jti: 'fixed-jti', iat: 1234 }) }) it('the DPoP signature verifies under the embedded public JWK (self-consistent proof)', async () => { const key = await createDpopKey({ subtle }) const proof = await key.proof('https://alice/ws', 'GET', 2000) const [h, p, s] = proof.split('.') as [string, string, string] const header = decodeJson(h) as { jwk: { x: string } } const pub = await subtle.importKey('raw', bytes(header.jwk.x), { name: 'Ed25519' }, true, ['verify']) const ok = await subtle.verify({ name: 'Ed25519' }, pub, bytes(s), utf8(`${h}.${p}`)) expect(ok).toBe(true) }) it('mints a FRESH jti per proof (no DPoP replay across upgrades)', async () => { const key = await createDpopKey({ subtle }) const a = await key.proof('https://alice/ws', 'GET', 3000) const b = await key.proof('https://alice/ws', 'GET', 3000) const jtiOf = (proof: string): unknown => (decodeJson(proof.split('.')[1] as string) as { jti: unknown }).jti expect(jtiOf(a)).not.toEqual(jtiOf(b)) }) it('fails fast (typed RelayWebError, no key material leaked) when Ed25519 keygen is unsupported', async () => { const brokenSubtle = { generateKey: async () => { throw new Error('Ed25519 not supported') }, } as unknown as SubtleCrypto await expect(createDpopKey({ subtle: brokenSubtle })).rejects.toThrow( /failed to generate an Ed25519 DPoP key/, ) }) it('htuFor mirrors relay-run: https:///ws', () => { expect(htuFor('alice')).toBe('https://alice/ws') }) }) describe('DPoP subprotocol carrier (encode/extract round-trip)', () => { it('encodes term.dpop. and extracts it back verbatim', () => { const proof = 'aGVhZGVy.cGF5bG9hZA.c2ln' const entry = encodeDpopSubprotocol(proof) expect(entry.startsWith(DPOP_SUBPROTOCOL_PREFIX)).toBe(true) expect(decodeBase64UrlString(entry.slice(DPOP_SUBPROTOCOL_PREFIX.length))).toBe(proof) expect(extractDpopFromSubprotocols(['term.relay.v1', entry])).toBe(proof) }) it('extract returns null when no DPoP entry is present', () => { expect(extractDpopFromSubprotocols(['term.relay.v1', 'term.token.QUJD'])).toBeNull() }) })