Skip to content

Phase 3: Cryptography Layer#8

Merged
wmxscott merged 10 commits into
mainfrom
claude/implement-phase-3-F4ERx
Mar 23, 2026
Merged

Phase 3: Cryptography Layer#8
wmxscott merged 10 commits into
mainfrom
claude/implement-phase-3-F4ERx

Conversation

@wmxscott
Copy link
Copy Markdown
Owner

Summary

  • Implements the complete cryptography layer using window.crypto.subtle only (no external libraries)
  • signing.ts: RSA (RSASSA-PKCS1-v1_5, 2048-bit) keypair generation, export/import, signBytes/verifySignature, nonce generation, and shared base64url encoding helpers
  • oaep.ts: RSA-OAEP keypair for wrapping/unwrapping AES-GCM room keys (owner + moderators only)
  • roomKey.ts: AES-GCM 256-bit room key generation with encrypt/decrypt (fresh random 96-bit 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 a structured ClaimToken with decoded header/payload/signature, decodeJWT for extracting claims without verification, verifyJWT with TOKEN_EXPIRED, INVALID_SIGNATURE, MALFORMED_TOKEN error reasons
  • Added SESSION_KEYS constant for sessionStorage keys
  • Set up Vitest with happy-dom environment

Test plan

  • 92 tests across 5 test files all passing
  • Signing keypair generation, export/import round-trips, sign/verify with correct and wrong keys
  • OAEP keypair generation, export/import round-trips, wrapRoomKey/unwrapRoomKey with correct and wrong keys
  • AES-GCM room key generation, export/import, encrypt/decrypt round-trips including unicode and large strings, tamper detection
  • Key storage: localStorage isolation by peerId/roomId, sessionStorage for guests
  • JWT: mint/decode/verify round-trips, tampered payload detection, expired token detection, malformed token handling
  • pnpm run build passes with no TypeScript errors

https://claude.ai/code/session_01HLU65ntdjvzaMkJtgBXfxX

- 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
Comment thread src/lib/jwt/index.ts Outdated
}

/** Verifies JWT signature and expiry against a given public key. */
export async function verifyJWT(token: string | ClaimToken, signingPublicKey: CryptoKey): Promise<VerifyResult> {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this function verify the expiry of the JWT too? Since it's part of its validity test

Comment thread src/constants/index.ts Outdated
} as const

// sessionStorage keys — die on tab close
export const SESSION_KEYS = {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread src/lib/crypto/storage.ts Outdated
return b64 ? importSigningPublicKey(b64) : null
}

export function loadSigningPublicKeyB64(peerId: string): string | null {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the point of this function? Why not just use loadSigningPublicKey instead?

Comment thread src/lib/jwt/index.ts
Comment thread src/lib/jwt/index.ts Outdated
export async function verifyJWT(token: string | ClaimToken, signingPublicKey: CryptoKey): Promise<VerifyResult> {
try {
const raw = typeof token === 'string' ? token : token.raw
const parts = raw.split('.')
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method should only accept type ClaimToken, no string

Comment thread src/lib/jwt/index.ts Outdated
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' }
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error should be thrown by the parse method, not the verify method

Comment thread src/lib/jwt/index.ts Outdated
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)
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're duplicating the decodeJWT method here

Comment thread src/lib/jwt/index.ts Outdated
{ name: 'RSASSA-PKCS1-v1_5' },
signingPublicKey,
base64urlToBuffer(s),
new TextEncoder().encode(signingInput),
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Always verify signature before you read any data in payload. Verify signature then check expiry

Comment thread src/lib/crypto/oaep.ts Outdated
return crypto.subtle.generateKey(OAEP_ALGO, true, ['encrypt', 'decrypt'])
}

export async function exportOaepKey(key: CryptoKey, type: 'public' | 'private'): Promise<string> {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer to have this split into exportOaepPrivateKey and exportOaepPublicKey

Comment thread src/lib/crypto/signing.ts Outdated
return crypto.subtle.generateKey(SIGN_ALGO, true, ['sign', 'verify'])
}

export async function exportSigningKey(key: CryptoKey, type: 'public' | 'private'): Promise<string> {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer to have this split into exportSigningPrivateKey and exportSigningPublicKey

Comment thread src/lib/crypto/storage.ts Outdated
}

/** Guest peer ID lives in sessionStorage — survives reload, dies on tab close. */
export function saveGuestPeerId(peerId: string): void {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove in favour of savePeerId

Comment thread src/lib/crypto/storage.ts Outdated
sessionStorage.setItem(SESSION_KEYS.GUEST_PEER_ID, peerId)
}

export function loadGuestPeerId(): string | null {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove in favour of loadPeerId

wmxscott and others added 3 commits March 22, 2026 18:11
- 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
Comment thread src/lib/jwt/index.ts
claude and others added 6 commits March 23, 2026 00:29
- 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
@wmxscott wmxscott merged commit 8677536 into main Mar 23, 2026
0 of 2 checks passed
@wmxscott wmxscott deleted the claude/implement-phase-3-F4ERx branch March 23, 2026 00:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants