// @ts-check /** * Cryptography for a session, using only the browser's Web Crypto API. * * An invitation carries a random *secret*. From it every participant * derives the *room id* (which the relay sees) and the *message key* (which * the relay never sees). Each participant also has a signing key pair; the * participant's id is a hash of the public key, so nobody else can claim it. * * See notes/protocol.md. */ const encoder = new TextEncoder(); const decoder = new TextDecoder(); const SECRET_PATTERN = /^[A-Za-z0-9_-]{22}$/; const SIGNATURE = { name: 'ECDSA', hash: 'SHA-256' }; const SIGNING_KEY = { name: 'ECDSA', namedCurve: 'P-256' }; /** * @param {Uint8Array} bytes * @returns {string} */ export function toBase64Url(bytes) { let binary = ''; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } /** * @param {string} text * @returns {Uint8Array} */ export function fromBase64Url(text) { const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/')); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes; } /** * Ordinary base64, as used in a `data:` address. Works on large arrays. * @param {Uint8Array} bytes * @returns {string} */ export function toBase64(bytes) { let binary = ''; for (let i = 0; i < bytes.length; i += 0x8000) { binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); } return btoa(binary); } /** * @param {string} text Ordinary base64. * @returns {Uint8Array} */ export function fromBase64(text) { const binary = atob(text); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes; } /** * @param {Uint8Array} bytes * @returns {Promise} SHA-256 of the bytes, base64url. */ export async function hashOf(bytes) { return toBase64Url(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))); } /** * A random identifier, for strokes and the like. * @param {number} [byteCount] * @returns {string} */ export function randomId(byteCount = 9) { return toBase64Url(crypto.getRandomValues(new Uint8Array(byteCount))); } /** * @typedef {object} Invitation * @property {string} relay Address of the relay, ws:// or wss://. * @property {string} secret The session secret. * @property {string} hostId Participant id of the host. */ /** * @param {Invitation} invitation * @returns {string} */ export function formatInvitation({ relay, secret, hostId }) { return `${relay}#${secret}.${hostId}`; } /** * An invitation as a link to a Kokkoja web site, which a guest can open * and be in the session with nothing installed. The invitation itself is * after the '#', which browsers never send to the site. * @param {string} site The site's address, such as https://kokkoja.example/. * @param {string} invitation From formatInvitation(). * @returns {string} */ export function formatInvitationLink(site, invitation) { // Only the '#' needs hiding; the rest stays readable. return `${site.replace(/\/*$/, '/')}#join=${invitation.replace('#', '%23')}`; } /** * The invitation in a piece of text: the invitation itself, or a link * that carries one. * @param {string} text * @returns {string} The invitation, or the text as it was. */ export function unwrapInvitation(text) { text = text.trim(); const link = /^https?:\/\/[^#\s]+#(.*)$/.exec(text); if (!link) return text; return new URLSearchParams(link[1]).get('join')?.trim() ?? text; } /** * @param {string} text An invitation, or a link that carries one. * @returns {Invitation | null} Null if the text is not an invitation. */ export function parseInvitation(text) { const match = /^(wss?:\/\/[^#\s]+)#([A-Za-z0-9_-]{22})\.([A-Za-z0-9_-]{22})$/.exec(unwrapInvitation(text)); if (!match) return null; return { relay: match[1].replace(/\/+$/, ''), secret: match[2], hostId: match[3] }; } /** @returns {string} */ export function newSecret() { return toBase64Url(crypto.getRandomValues(new Uint8Array(16))); } /** * Derive the room id and the message key from the session secret. * @param {string} secret * @returns {Promise<{roomId: string, key: CryptoKey}>} */ export async function deriveSession(secret) { if (!SECRET_PATTERN.test(secret)) throw new Error('bad session secret'); const material = await crypto.subtle.importKey( 'raw', fromBase64Url(secret), 'HKDF', false, ['deriveBits', 'deriveKey']); const salt = new Uint8Array(32); const roomBits = await crypto.subtle.deriveBits( { name: 'HKDF', hash: 'SHA-256', salt, info: encoder.encode('kokkoja 1 room') }, material, 128); const key = await crypto.subtle.deriveKey( { name: 'HKDF', hash: 'SHA-256', salt, info: encoder.encode('kokkoja 1 key') }, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); return { roomId: toBase64Url(new Uint8Array(roomBits)), key }; } /** * Encrypt a message. The result is a 12-byte nonce followed by the * ciphertext. * @param {CryptoKey} key * @param {object} message * @returns {Promise} */ export async function encrypt(key, message) { const nonce = crypto.getRandomValues(new Uint8Array(12)); const ciphertext = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: nonce }, key, encoder.encode(JSON.stringify(message))); const result = new Uint8Array(12 + ciphertext.byteLength); result.set(nonce); result.set(new Uint8Array(ciphertext), 12); return result; } /** * Decrypt a message. Rejects if the message was not made with this key. * @param {CryptoKey} key * @param {Uint8Array} bytes * @returns {Promise} */ export async function decrypt(key, bytes) { const plaintext = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: bytes.subarray(0, 12) }, key, bytes.subarray(12)); return JSON.parse(decoder.decode(plaintext)); } /** * A participant's identity: a signing key pair and the id derived from it. * @typedef {object} Identity * @property {string} id First 16 bytes of SHA-256 of the public key. * @property {string} publicKey Raw public key, base64url. * @property {CryptoKey} privateKey */ /** * @param {Uint8Array} publicKeyBytes * @returns {Promise} */ async function idOfPublicKey(publicKeyBytes) { const digest = await crypto.subtle.digest('SHA-256', publicKeyBytes); return toBase64Url(new Uint8Array(digest, 0, 16)); } /** @returns {Promise} */ export async function newIdentity() { const pair = await crypto.subtle.generateKey(SIGNING_KEY, true, ['sign', 'verify']); const publicKeyBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)); return { id: await idOfPublicKey(publicKeyBytes), publicKey: toBase64Url(publicKeyBytes), privateKey: pair.privateKey, }; } /** * @param {Identity} identity * @returns {Promise} Text that loadIdentity() accepts. */ export async function saveIdentity(identity) { const jwk = await crypto.subtle.exportKey('jwk', identity.privateKey); return JSON.stringify({ publicKey: identity.publicKey, privateKey: jwk }); } /** * @param {string} text * @returns {Promise} */ export async function loadIdentity(text) { const stored = JSON.parse(text); const privateKey = await crypto.subtle.importKey( 'jwk', stored.privateKey, SIGNING_KEY, true, ['sign']); return { id: await idOfPublicKey(fromBase64Url(stored.publicKey)), publicKey: stored.publicKey, privateKey, }; } /** * The statement a participant signs to claim a relay connection: "the * holder of this key is peer number `peer` in room `roomId`". * @param {string} roomId * @param {number} peer */ function claimText(roomId, peer) { return encoder.encode(`kokkoja 1 hello ${roomId} ${peer}`); } /** * @param {Identity} identity * @param {string} roomId * @param {number} peer * @returns {Promise} The signature, base64url. */ export async function signClaim(identity, roomId, peer) { const signature = await crypto.subtle.sign(SIGNATURE, identity.privateKey, claimText(roomId, peer)); return toBase64Url(new Uint8Array(signature)); } /** * Check a claim made by another participant. * @param {string} publicKey Raw public key, base64url. * @param {string} signature From signClaim(), base64url. * @param {string} roomId * @param {number} peer * @returns {Promise} The participant id, or null if the * claim is not valid. */ export async function verifyClaim(publicKey, signature, roomId, peer) { try { const publicKeyBytes = fromBase64Url(publicKey); const key = await crypto.subtle.importKey('raw', publicKeyBytes, SIGNING_KEY, false, ['verify']); const valid = await crypto.subtle.verify( SIGNATURE, key, fromBase64Url(signature), claimText(roomId, peer)); return valid ? await idOfPublicKey(publicKeyBytes) : null; } catch { return null; } }