import { describe, it, expect, beforeEach } from 'vitest' import { issueCapabilityToken } from '../src/capability/issue.js' import { verifyCapabilityToken, verifyDpopProof, buildDpopProof, readCnfJkt, subAccountId, hasRight, resetDpopCacheForTest, } from '../src/capability/verify.js' import { jwkThumbprint } from '../src/crypto/thumbprint.js' import { CapabilityError } from '../src/capability/errors.js' import { setupP5SigningKey, makeEphemeral, principal, uuid } from './_helpers.js' const NOW = 1_700_000_000 const AUD = 'alice.term.example.com' async function issueFor( signingKey: CryptoKey, opts: { accountId?: string; host?: string; rights?: readonly ('attach' | 'manage' | 'kill')[]; ttl?: number; cnfJkt?: string }, ) { const cnfJkt = opts.cnfJkt ?? (await jwkThumbprint((await makeEphemeral()).publicRaw)) return issueCapabilityToken( { principal: principal(opts.accountId ?? 'acct-A'), aud: AUD, host: opts.host ?? uuid(), rights: opts.rights ?? ['attach'], ttlSeconds: opts.ttl ?? 45, cnfJkt, }, signingKey, NOW, ) } describe('capability token (§4.3)', () => { let signingKey: CryptoKey beforeEach(async () => { ;({ signingKey } = await setupP5SigningKey()) resetDpopCacheForTest() }) it('round-trips issue → verify preserving host/rights/jti and sub===accountId', async () => { const host = uuid() const raw = await issueFor(signingKey, { accountId: 'acct-A', host, rights: ['attach', 'manage'] }) const tok = await verifyCapabilityToken(raw, AUD, NOW + 1) expect(tok.host).toBe(host) expect(tok.rights).toEqual(['attach', 'manage']) expect(tok.jti.length).toBeGreaterThan(0) expect(subAccountId(tok)).toBe('acct-A') }) it('sets sub to principal.accountId, never a client value', async () => { const raw = await issueFor(signingKey, { accountId: 'acct-A' }) const tok = await verifyCapabilityToken(raw, AUD, NOW) expect(tok.sub).toBe('acct-A') }) it('rejects an expired token', async () => { const raw = await issueFor(signingKey, { ttl: 30 }) await expect(verifyCapabilityToken(raw, AUD, NOW + 31)).rejects.toMatchObject({ reason: 'expired' }) }) it('rejects a not-yet-valid token (iat in the future beyond skew)', async () => { const raw = await issueFor(signingKey, {}) await expect(verifyCapabilityToken(raw, AUD, NOW - 100)).rejects.toMatchObject({ reason: 'not_yet_valid', }) }) it('refuses an over-long TTL at issue (no long-lived reusable token)', async () => { await expect(issueFor(signingKey, { ttl: 61 })).rejects.toMatchObject({ reason: 'ttl_too_long' }) }) it('clamps a too-short TTL up to the 30s floor', async () => { const raw = await issueFor(signingKey, { ttl: 5 }) const tok = await verifyCapabilityToken(raw, AUD, NOW) expect(tok.exp - tok.iat).toBe(30) }) it('rejects a wildcard host at issue', async () => { await expect(issueFor(signingKey, { host: '*' })).rejects.toMatchObject({ reason: 'wildcard_host' }) }) it('rejects wrong aud (Host-confusion, INV1)', async () => { const raw = await issueFor(signingKey, {}) await expect(verifyCapabilityToken(raw, 'bob.term.example.com', NOW)).rejects.toMatchObject({ reason: 'aud_mismatch', }) }) it('rejects a tampered payload (signature fails)', async () => { const raw = await issueFor(signingKey, {}) const tampered = raw.slice(0, -4) + (raw.endsWith('AAAA') ? 'BBBB' : 'AAAA') await expect(verifyCapabilityToken(tampered, AUD, NOW)).rejects.toBeInstanceOf(CapabilityError) }) it('rejects a token signed by a different key', async () => { const other = await setupP5SigningKey() // reconfigures verify key to a DIFFERENT pair const raw = await issueFor(other.signingKey, {}) // reconfigure back to the original key so the verifier uses the wrong public key await setupP5SigningKey() await expect(verifyCapabilityToken(raw, AUD, NOW)).rejects.toMatchObject({ reason: 'bad_signature' }) }) it('enforces least-privilege rights (INV15)', async () => { const raw = await issueFor(signingKey, { rights: ['attach'] }) const tok = await verifyCapabilityToken(raw, AUD, NOW) expect(hasRight(tok, 'attach')).toBe(true) expect(hasRight(tok, 'kill')).toBe(false) }) describe('DPoP proof-of-possession', () => { it('accepts a proof from the bound ephemeral key and rejects a different key', async () => { const eph = await makeEphemeral() const cnfJkt = await jwkThumbprint(eph.publicRaw) const raw = await issueFor(signingKey, { cnfJkt }) const tok = await verifyCapabilityToken(raw, AUD, NOW) expect(readCnfJkt(tok)).toBe(cnfJkt) const htu = 'https://alice.term.example.com/ws' const good = await buildDpopProof(eph.privateKey, eph.publicRaw, { htu, htm: 'GET', jti: uuid(), iat: NOW, }) expect(await verifyDpopProof(tok, { proofJws: good, htu, htm: 'GET' }, NOW)).toBe(true) const wrong = await makeEphemeral() const bad = await buildDpopProof(wrong.privateKey, wrong.publicRaw, { htu, htm: 'GET', jti: uuid(), iat: NOW, }) expect(await verifyDpopProof(tok, { proofJws: bad, htu, htm: 'GET' }, NOW)).toBe(false) }) it('rejects a replayed DPoP proof (same jti reused)', async () => { const eph = await makeEphemeral() const cnfJkt = await jwkThumbprint(eph.publicRaw) const raw = await issueFor(signingKey, { cnfJkt }) const tok = await verifyCapabilityToken(raw, AUD, NOW) const htu = 'https://alice.term.example.com/ws' const jti = uuid() const proof = await buildDpopProof(eph.privateKey, eph.publicRaw, { htu, htm: 'GET', jti, iat: NOW }) expect(await verifyDpopProof(tok, { proofJws: proof, htu, htm: 'GET' }, NOW)).toBe(true) expect(await verifyDpopProof(tok, { proofJws: proof, htu, htm: 'GET' }, NOW)).toBe(false) }) }) })