Phase 3: Cryptography Layer#8
Conversation
- signing.ts: RSA (RSASSA-PKCS1-v1_5) keypair generation, export/import, signBytes/verifySignature, nonce generation, base64url encoding helpers - oaep.ts: RSA-OAEP keypair for wrapping/unwrapping AES-GCM room keys - roomKey.ts: AES-GCM 256-bit room key with encrypt/decrypt (fresh IV per call) - storage.ts: localStorage persistence for signing/OAEP/room keys keyed by peerId, sessionStorage for guest peer IDs - jwt/index.ts: mintJWT (returns structured ClaimToken), decodeJWT, verifyJWT with TOKEN_EXPIRED, INVALID_SIGNATURE, MALFORMED_TOKEN error reasons All crypto uses window.crypto.subtle only — no external libraries. Test suite: 92 tests across 5 test files covering all modules including round-trip verification, tampering detection, key isolation, and edge cases. https://claude.ai/code/session_01HLU65ntdjvzaMkJtgBXfxX
| } | ||
|
|
||
| /** Verifies JWT signature and expiry against a given public key. */ | ||
| export async function verifyJWT(token: string | ClaimToken, signingPublicKey: CryptoKey): Promise<VerifyResult> { |
There was a problem hiding this comment.
Should this function verify the expiry of the JWT too? Since it's part of its validity test
| } as const | ||
|
|
||
| // sessionStorage keys — die on tab close | ||
| export const SESSION_KEYS = { |
There was a problem hiding this comment.
I see that you introduced this again. I suggested in the past to simply use the same peerId for all peers, regardless of the role. What's your reasoning for bringing it back?
| return b64 ? importSigningPublicKey(b64) : null | ||
| } | ||
|
|
||
| export function loadSigningPublicKeyB64(peerId: string): string | null { |
There was a problem hiding this comment.
What's the point of this function? Why not just use loadSigningPublicKey instead?
| export async function verifyJWT(token: string | ClaimToken, signingPublicKey: CryptoKey): Promise<VerifyResult> { | ||
| try { | ||
| const raw = typeof token === 'string' ? token : token.raw | ||
| const parts = raw.split('.') |
There was a problem hiding this comment.
This method should only accept type ClaimToken, no string
| try { | ||
| const raw = typeof token === 'string' ? token : token.raw | ||
| const parts = raw.split('.') | ||
| if (parts.length !== 3) return { valid: false, payload: null, reason: 'MALFORMED_TOKEN' } |
There was a problem hiding this comment.
This error should be thrown by the parse method, not the verify method
| const parts = raw.split('.') | ||
| if (parts.length !== 3) return { valid: false, payload: null, reason: 'MALFORMED_TOKEN' } | ||
| const [h, p, s] = parts | ||
| const payload = decodeB64url<JWTPayload>(p) |
There was a problem hiding this comment.
You're duplicating the decodeJWT method here
| { name: 'RSASSA-PKCS1-v1_5' }, | ||
| signingPublicKey, | ||
| base64urlToBuffer(s), | ||
| new TextEncoder().encode(signingInput), |
There was a problem hiding this comment.
Always verify signature before you read any data in payload. Verify signature then check expiry
| return crypto.subtle.generateKey(OAEP_ALGO, true, ['encrypt', 'decrypt']) | ||
| } | ||
|
|
||
| export async function exportOaepKey(key: CryptoKey, type: 'public' | 'private'): Promise<string> { |
There was a problem hiding this comment.
Prefer to have this split into exportOaepPrivateKey and exportOaepPublicKey
| return crypto.subtle.generateKey(SIGN_ALGO, true, ['sign', 'verify']) | ||
| } | ||
|
|
||
| export async function exportSigningKey(key: CryptoKey, type: 'public' | 'private'): Promise<string> { |
There was a problem hiding this comment.
Prefer to have this split into exportSigningPrivateKey and exportSigningPublicKey
| } | ||
|
|
||
| /** Guest peer ID lives in sessionStorage — survives reload, dies on tab close. */ | ||
| export function saveGuestPeerId(peerId: string): void { |
There was a problem hiding this comment.
Remove in favour of savePeerId
| sessionStorage.setItem(SESSION_KEYS.GUEST_PEER_ID, peerId) | ||
| } | ||
|
|
||
| export function loadGuestPeerId(): string | null { |
There was a problem hiding this comment.
Remove in favour of loadPeerId
- Remove SESSION_KEYS: use single PEER_ID for all roles (no guest-specific peer ID) - Split exportSigningKey into exportSigningPrivateKey / exportSigningPublicKey - Split exportOaepKey into exportOaepPrivateKey / exportOaepPublicKey - Remove loadSigningPublicKeyB64 from storage (use loadSigningPublicKey instead) - Remove saveGuestPeerId / loadGuestPeerId from storage - mintJWT: auto-generate jti internally (callers no longer pass it) - verifyJWT: accept ClaimToken only (not string); verify signature first, then expiry - decodeJWT: throw MALFORMED_TOKEN on wrong part count (responsibility moved from verifyJWT) - Update all tests to match new APIs (87 passing) https://claude.ai/code/session_01HLU65ntdjvzaMkJtgBXfxX
- mintJWT now takes issuerId as an explicit separate parameter - Add GitHub Actions CI workflow: runs tests, build, and lint on PRs and main merges https://claude.ai/code/session_01HLU65ntdjvzaMkJtgBXfxX
- Extract `iss` from mintJWT payload into explicit `issuerId` parameter - Add prettier (v3) with eslint-config-prettier integration - Format entire codebase with prettier - Update CI workflow: - Use actions/checkout@v6 - Use Node 24 (LTS) via node:24-alpine container for smaller image - Use pnpm instead of npm - Add format check job alongside test, build, lint https://claude.ai/code/session_01HLU65ntdjvzaMkJtgBXfxX
- Use ubuntu-24.04-arm (smaller/faster runner) instead of ubuntu-latest - Consolidate 4 separate jobs into one to avoid redundant pnpm installs - Reorder steps: build -> lint -> format:check -> test https://claude.ai/code/session_01HLU65ntdjvzaMkJtgBXfxX
Drop the redundant container: node:24-alpine layer — no need to run alpine inside ubuntu. Use actions/setup-node@v4 with node 24 directly on the ubuntu-24.04-arm runner. https://claude.ai/code/session_01HLU65ntdjvzaMkJtgBXfxX
- Switch to pnpm/action-setup@v5 for pnpm setup (no corepack needed) - Upgrade actions/setup-node to v6 - Enable pnpm dependency caching https://claude.ai/code/session_01HLU65ntdjvzaMkJtgBXfxX
Summary
window.crypto.subtleonly (no external libraries)mintJWTreturns a structuredClaimTokenwith decoded header/payload/signature,decodeJWTfor extracting claims without verification,verifyJWTwithTOKEN_EXPIRED,INVALID_SIGNATURE,MALFORMED_TOKENerror reasonsSESSION_KEYSconstant for sessionStorage keysTest plan
pnpm run buildpasses with no TypeScript errorshttps://claude.ai/code/session_01HLU65ntdjvzaMkJtgBXfxX