Phase 6: Peer Admission Protocol#11
Conversation
… hook Implements the peer admission protocol as specified in the implementation plan: - `src/lib/admission/index.ts`: Core admission logic with `isOwnerSelfAdmit`, `ownerSelfAdmit`, `issueNonce`, and `evaluateAdmission`. The owner evaluates peer admission requests, verifying JWT signatures, nonce proofs, and wrapping the AES-GCM room key for admitted moderators via RSA-OAEP. Guests never receive the room key. Reconnecting peers are verified against the burned JTI registry to prevent public key swaps. - `src/lib/room/useSession.ts`: React hook that resolves the current peer's session state. Owner self-admits instantly when no other peers are connected. Moderators and guests without an owner online enter the lobby. When the owner is present the hook signals PENDING_HANDSHAKE for the event-driven WebRTC handshake to complete. - `src/constants/index.ts`: Added `GUEST_PEER_ID` storage key for ephemeral guest identity persistence. - `src/lib/crypto/storage.ts`: Added `loadSigningPublicKeyB64`, `loadGuestPeerId`, and `saveGuestPeerId` helpers required by the session hook. Comprehensive tests cover all acceptance criteria: owner self-admit, lobby states, first admission and reconnection flows, tampered/expired token rejection, PUBKEY_MISMATCH on key-swap attempts, and the invariant that guests never receive an encrypted room key. https://claude.ai/code/session_01V93TGVeBjjHgdWZpTsVBWH
Run prettier after final test edits — previously format was run before the test fixes, so the final edits were not formatted before the commit. https://claude.ai/code/session_01V93TGVeBjjHgdWZpTsVBWH
wmxscott
left a comment
There was a problem hiding this comment.
Looks good overall, some feedback on some changes below.
Also, remove the .gitkeep from lib/admission as no longer needed.
Also, try to move some of the logic from useSession.ts to the lib/admission package.
| ROOM_KEY: 'ratifyd:roomkey', // AES-GCM room key (owner + moderators) | ||
| PEER_ID: 'ratifyd:peerId', // Stable identity UUID | ||
| PEER_ID: 'ratifyd:peerId', // Stable identity UUID (owner + moderators) | ||
| GUEST_PEER_ID: 'ratifyd:guestPeerId', // Ephemeral guest identity UUID |
There was a problem hiding this comment.
No need for this, use PEER_ID instead
| roomId: string | ||
| } | ||
|
|
||
| export interface AdmissionResult { |
There was a problem hiding this comment.
I prefer this type to be more specific so that you cannot pass admitted as true or encrypted room key if the admission fails. Set it to:
{
admitted: true
role: Role
encryptedRoomKey: string | null // only for moderators on first admission
reason: null
} | {
admitted: false
role: null
encryptedRoomKey: null
reason: string
}|
|
||
| if (payload.room !== roomId) return fail('WRONG_ROOM') | ||
|
|
||
| const isReconnect = shared.burnedJTIs.has(payload.jti) |
There was a problem hiding this comment.
Evaluate the isReconnect immediately before the if usage, and after the JWT signature has been verified. In fact, don't create an unnecessary constant, just evaluate the value right within the if condition
| import type { JWTPayload } from '../../constants' | ||
| import type { SharedTypes } from '../yjs/doc' | ||
|
|
||
| export interface AdmissionParams { |
There was a problem hiding this comment.
Rename to AdmissionRequest instead of AdmissionParams
| roomId: string | ||
| } | ||
|
|
||
| export interface AdmissionResult { |
There was a problem hiding this comment.
Rename to AdmissionResponse instead of AdmissionResult
| return localStorage.getItem(STORAGE_KEYS.PEER_ID) | ||
| } | ||
|
|
||
| export function saveGuestPeerId(peerId: string): void { |
There was a problem hiding this comment.
No need for this, use savePeerId instead
| localStorage.setItem(STORAGE_KEYS.GUEST_PEER_ID, peerId) | ||
| } | ||
|
|
||
| export function loadGuestPeerId(): string | null { |
There was a problem hiding this comment.
No need for this, use loadPeerId instead
- Rename AdmissionParams → AdmissionRequest, AdmissionResult → AdmissionResponse - Make AdmissionResponse a discriminated union for precise typing - Inline isReconnect check directly into if condition - Remove GUEST_PEER_ID constant; guests now share STORAGE_KEYS.PEER_ID - Remove saveGuestPeerId/loadGuestPeerId; callers use savePeerId/loadPeerId https://claude.ai/code/session_01V93TGVeBjjHgdWZpTsVBWH
Document that all roles share STORAGE_KEYS.PEER_ID and that GUEST_PEER_ID / saveGuestPeerId / loadGuestPeerId must not be re-added. https://claude.ai/code/session_01V93TGVeBjjHgdWZpTsVBWH
Summary
Implements the Peer Admission Protocol (Phase 6) as specified in the implementation plan.
src/lib/admission/index.ts— Core admission logic:isOwnerSelfAdmit: Checks all four conditions for owner self-admissionownerSelfAdmit: Idempotent self-admission writing to Yjs shared docissueNonce: Verifier-issued challenge nonce (prevents replay attacks)evaluateAdmission: Full Round 4 evaluation — verifies JWT, nonce proof, wraps room key for moderators via RSA-OAEP. Guests never receive a room key. Reconnect detected via burned JTI registry, PUBKEY_MISMATCH prevents key-swap attacks.src/lib/room/useSession.ts— React hook resolving session state:needsLobby: truePENDING_HANDSHAKEfor event-driven WebRTC handshakesrc/constants/index.ts— AddedGUEST_PEER_IDstorage keysrc/lib/crypto/storage.ts— AddedloadSigningPublicKeyB64,loadGuestPeerId,saveGuestPeerIdTest plan
pnpm run build)pnpm run lint)pnpm run format)pnpm test)ready: true, role: ownerneedsLobby: trueINVALID_SIGNATURE; expired ->TOKEN_EXPIRED; wrong key ->PUBKEY_MISMATCHencryptedRoomKey(TypeScript enforcesstring | null)https://claude.ai/code/session_01V93TGVeBjjHgdWZpTsVBWH