2026-10-06 15:58:02 +02:00
|
|
|
// @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<ArrayBuffer>}
|
|
|
|
|
*/
|
|
|
|
|
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<ArrayBuffer>}
|
|
|
|
|
*/
|
|
|
|
|
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<ArrayBuffer>} bytes
|
|
|
|
|
* @returns {Promise<string>} 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.
|
2026-10-18 20:22:13 +02:00
|
|
|
* @property {string} hostId Participant id of whoever started the
|
|
|
|
|
* session (named the host until 2026-10-18; the field keeps its name
|
|
|
|
|
* so that invitations and page files are unchanged). It gives no
|
|
|
|
|
* right.
|
2026-10-06 15:58:02 +02:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @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<Uint8Array>}
|
|
|
|
|
*/
|
|
|
|
|
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<ArrayBuffer>} bytes
|
|
|
|
|
* @returns {Promise<any>}
|
|
|
|
|
*/
|
|
|
|
|
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<ArrayBuffer>} publicKeyBytes
|
|
|
|
|
* @returns {Promise<string>}
|
|
|
|
|
*/
|
|
|
|
|
async function idOfPublicKey(publicKeyBytes) {
|
|
|
|
|
const digest = await crypto.subtle.digest('SHA-256', publicKeyBytes);
|
|
|
|
|
return toBase64Url(new Uint8Array(digest, 0, 16));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @returns {Promise<Identity>} */
|
|
|
|
|
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<string>} 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<Identity>}
|
|
|
|
|
*/
|
|
|
|
|
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<string>} 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<string | null>} 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;
|
|
|
|
|
}
|
|
|
|
|
}
|