RELAY-PHASE1 Wave A (2/3) + E1 infra: - A1: createPgStores() Postgres adapter for all 9 P3 store ports + runMigrations(); writable-CTE atomic INV8 status swaps; 0002_routes.sql. 23/23 pg tests, 96.72% cov. - A3: real capability verifier delegating to relay-auth verifyCapabilityToken; sync->async seam across authz/provision/main. Full CP suite 15 files/101 pass, tsc clean. - E1: deploy/docker-compose.yml (Postgres16+Redis7, loopback-only) + .env.example. - docs: PLAN_RELAY_PHASE1.md file-level execution spec; PROGRESS_LOG RELAY-PHASE1 section.
79 lines
3.1 KiB
TypeScript
79 lines
3.1 KiB
TypeScript
/**
|
|
* A3 — REAL capability verifier (boot/verifier.ts) backed by relay-auth's async `verifyCapabilityToken`.
|
|
* Mints tokens with the paired Ed25519 private key via P5's `issueCapabilityToken`, configures the
|
|
* matching public key, and asserts: a good token verifies; wrong `aud`, past `exp`, and a token
|
|
* signed by a NON-matching key all reject (deny-by-default, INV6).
|
|
*/
|
|
import { describe, test, expect, beforeAll } from 'vitest'
|
|
import { issueCapabilityToken } from 'relay-auth'
|
|
import type { AuthenticatedPrincipal } from 'relay-auth'
|
|
import { encodeBase64UrlBytes } from 'relay-contracts'
|
|
import { createCapabilityVerifier, configureCapabilityVerifyKey } from '../src/boot/verifier.js'
|
|
import type { CapabilityVerifier } from '../src/api/authz.js'
|
|
|
|
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
|
|
const AUD = 'term.example.com'
|
|
const NOW = 1_000_000
|
|
// A base64url SHA-256 JWK thumbprint is exactly 43 chars of [A-Za-z0-9_-]; 32 bytes → 43 base64url chars.
|
|
const CNF_JKT = encodeBase64UrlBytes(new Uint8Array(32).fill(7))
|
|
|
|
const principal: AuthenticatedPrincipal = {
|
|
kind: 'human',
|
|
accountId: ACCOUNT_A,
|
|
principalId: 'cred-1',
|
|
amr: ['passkey'],
|
|
authAt: NOW - 10,
|
|
stepUpAt: null,
|
|
}
|
|
|
|
type KeyPair = { publicKey: CryptoKey; privateKey: CryptoKey }
|
|
async function genKeyPair(): Promise<KeyPair> {
|
|
// generateKey's named-algorithm overload is typed as CryptoKey; Ed25519 yields a pair at runtime.
|
|
return (await globalThis.crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as KeyPair
|
|
}
|
|
|
|
function mint(signingKey: CryptoKey, now: number): Promise<string> {
|
|
return issueCapabilityToken(
|
|
{ principal, aud: AUD, host: 'host-1', rights: ['manage'], ttlSeconds: 60, cnfJkt: CNF_JKT },
|
|
signingKey,
|
|
now,
|
|
)
|
|
}
|
|
|
|
describe('A3 real capability verifier (relay-auth verifyCapabilityToken)', () => {
|
|
let signingKey: CryptoKey
|
|
let verifier: CapabilityVerifier
|
|
|
|
beforeAll(async () => {
|
|
const pair = await genKeyPair()
|
|
signingKey = pair.privateKey
|
|
const rawPub = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', pair.publicKey))
|
|
await configureCapabilityVerifyKey(rawPub)
|
|
verifier = createCapabilityVerifier()
|
|
})
|
|
|
|
test('verifies a token signed by the configured key and returns the account principal', async () => {
|
|
const raw = await mint(signingKey, NOW)
|
|
const token = await verifier.verify(raw, AUD, NOW)
|
|
expect(token.sub).toBe(ACCOUNT_A)
|
|
expect(token.aud).toBe(AUD)
|
|
expect(token.rights).toContain('manage')
|
|
})
|
|
|
|
test('rejects a token minted for a different aud (Host-confusion guard)', async () => {
|
|
const raw = await mint(signingKey, NOW)
|
|
await expect(verifier.verify(raw, 'evil.example.com', NOW)).rejects.toThrow()
|
|
})
|
|
|
|
test('rejects an expired token', async () => {
|
|
const raw = await mint(signingKey, NOW) // exp = NOW + 60
|
|
await expect(verifier.verify(raw, AUD, NOW + 120)).rejects.toThrow()
|
|
})
|
|
|
|
test('rejects a token signed by a NON-matching key (bad signature)', async () => {
|
|
const other = await genKeyPair()
|
|
const raw = await mint(other.privateKey, NOW)
|
|
await expect(verifier.verify(raw, AUD, NOW)).rejects.toThrow()
|
|
})
|
|
})
|