Kokkoja: first public assembly
Assembled from the development repository by tools/make_release.sh. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
95c80d04c9
39 files changed
+9905
No files matched your search
@@ -0,0 +1,278 @@
|
||||
// @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.
|
||||
* @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<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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user