clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/fix-sync-enrollment-startup
lofi / package / schema / shared-crypto.ts
shared-crypto.ts 12.4K
1/**2 * Shared-field crypto core: the primitives that let a field key travel to3 * every member of a group as ordinary synced data the server cannot open.4 *5 * Two formats live here. `wrap1` is an authenticated key wrap: a random6 * 32-byte field key sealed to one recipient's x25519 public key, with the7 * sender's static key mixed into the derivation, so the server — which knows8 * every public key — cannot mint a wrap of its own; forging one requires a9 * member's secret. `encs1` is the sealed column value: XChaCha20-Poly130510 * under a per-column subkey of the field key, with the key scope (group11 * table, group id, generation) carried in the stored string, because the12 * synchronous column transform sees only the value and must pick the right13 * key from an in-memory keyring.14 *15 * Both formats bind their full context as associated data — a wrap replayed16 * to another group, recipient, or generation, and a sealed value replayed to17 * another column or scope, fail authentication rather than decrypt.18 *19 * @module20 */21import { xchacha20poly1305 } from "@noble/ciphers/chacha";22import { x25519 } from "@noble/curves/ed25519";23import { hkdf } from "@noble/hashes/hkdf";24import { sha256 } from "@noble/hashes/sha2";25import { padPayload, unpadPayload } from "./padding.ts";2627const textEncoder = new TextEncoder();28const textDecoder = new TextDecoder();2930/**31 * Stable categories for shared-field failures. `identity-missing` — a shared32 * column was touched before the runtime installed the account's x2551933 * identity. `key-pending` — no field key is installed for the value's scope34 * and generation yet; a normal state for a freshly added member.35 * `unscoped-write` — a write reached the column transform without the36 * mutation layer sealing it first. `corrupt` — a sealed value failed37 * authentication. `peer-key-changed` — a peer's published public key no38 * longer matches its pinned fingerprint. `wrap-invalid` — a wrapped key39 * failed authentication or shape checks. `no-directory-entry` — a wrap was40 * requested for an account that has not published a public key.41 */42export type SharedFieldErrorCode =43 | "identity-missing"44 | "key-pending"45 | "unscoped-write"46 | "corrupt"47 | "peer-key-changed"48 | "wrap-invalid"49 | "no-directory-entry";5051/** Raised when shared-field material cannot be derived, wrapped, or opened. */52export class SharedFieldError extends Error {53 /** Stable error class name for diagnostics and error boundaries. */54 override readonly name = "SharedFieldError";55 /** The failure's stable category; see {@link SharedFieldErrorCode}. */56 readonly code: SharedFieldErrorCode;57 /** Creates a shared-field failure with a stable category. */58 constructor(code: SharedFieldErrorCode, message: string) {59 super(message);60 this.code = code;61 }62}6364/** True when an error came from the lofi shared-field surface. */65export function isSharedFieldError(error: unknown): error is SharedFieldError {66 return error instanceof SharedFieldError;67}6869function toBase64Url(bytes: Uint8Array): string {70 let binary = "";71 for (const byte of bytes) binary += String.fromCharCode(byte);72 return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");73}7475function fromBase64Url(text: string): Uint8Array {76 const base64 = text.replaceAll("-", "+").replaceAll("_", "/");77 const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);78 const binary = atob(padded);79 const bytes = new Uint8Array(binary.length);80 for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);81 return bytes;82}8384function concatBytes(...parts: Uint8Array[]): Uint8Array {85 const total = parts.reduce((sum, part) => sum + part.length, 0);86 const joined = new Uint8Array(total);87 let offset = 0;88 for (const part of parts) {89 joined.set(part, offset);90 offset += part.length;91 }92 return joined;93}9495/** The x25519 public key of a shared-field identity secret. */96export function sharedFieldPublicKey(identitySecret: Uint8Array): Uint8Array {97 if (identitySecret.length !== 32) {98 throw new SharedFieldError("identity-missing", "the x25519 identity secret must be 32 bytes");99 }100 return x25519.getPublicKey(identitySecret);101}102103/**104 * The pinnable fingerprint of a public key: base64url(SHA-256(key)). This is105 * the value carried in `lofi2` sharing-identity strings and compared by the106 * pin store.107 */108export function publicKeyFingerprint(publicKey: Uint8Array): string {109 return toBase64Url(sha256(publicKey));110}111112/** Mints a random 32-byte field key. */113export function generateFieldKey(): Uint8Array {114 return crypto.getRandomValues(new Uint8Array(32));115}116117/** The row context a wrapped key is bound to; any mismatch refuses to open. */118export type WrapContext = {119 groupTable: string;120 groupId: string;121 generation: number;122 recipientUserId: string;123 senderUserId: string;124};125126const WRAP_PREFIX = "wrap1.";127const WRAP_INFO = "lofi:shared-fields:wrap:v1";128const NONCE_LENGTH = 24;129const KEY_LENGTH = 32;130131function wrapAssociatedData(context: WrapContext): Uint8Array {132 return textEncoder.encode(133 `${WRAP_INFO}|${context.groupTable}|${context.groupId}|${context.generation}|` +134 `${context.recipientUserId}|${context.senderUserId}`,135 );136}137138function wrapKeyMaterial(139 ephemeralPublic: Uint8Array,140 senderPublic: Uint8Array,141 recipientPublic: Uint8Array,142 sharedEphemeral: Uint8Array,143 sharedStatic: Uint8Array,144): Uint8Array {145 return hkdf(146 sha256,147 concatBytes(sharedEphemeral, sharedStatic),148 concatBytes(ephemeralPublic, senderPublic, recipientPublic),149 WRAP_INFO,150 KEY_LENGTH,151 );152}153154/**155 * Wraps a field key to one recipient. The derivation mixes an ephemeral156 * exchange with the sender's static key (authcrypt): the recipient verifies157 * the wrap could only come from the holder of the sender's secret, so a158 * server that knows every public key still cannot forge one.159 */160export function wrapFieldKey(input: {161 fieldKey: Uint8Array;162 senderSecret: Uint8Array;163 recipientPublic: Uint8Array;164 context: WrapContext;165}): string {166 if (input.fieldKey.length !== KEY_LENGTH) {167 throw new SharedFieldError("wrap-invalid", "the field key must be 32 bytes");168 }169 const senderPublic = sharedFieldPublicKey(input.senderSecret);170 const ephemeralSecret = crypto.getRandomValues(new Uint8Array(KEY_LENGTH));171 const ephemeralPublic = x25519.getPublicKey(ephemeralSecret);172 const key = wrapKeyMaterial(173 ephemeralPublic,174 senderPublic,175 input.recipientPublic,176 x25519.getSharedSecret(ephemeralSecret, input.recipientPublic),177 x25519.getSharedSecret(input.senderSecret, input.recipientPublic),178 );179 const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));180 const cipher = xchacha20poly1305(key, nonce, wrapAssociatedData(input.context));181 const ciphertext = cipher.encrypt(input.fieldKey);182 return WRAP_PREFIX + toBase64Url(concatBytes(ephemeralPublic, nonce, ciphertext));183}184185/**186 * Opens a wrapped field key as its recipient. `senderPublic` must be the187 * sender's pinned public key — resolving it through the pin store is what188 * turns server key substitution into a detected failure here.189 */190export function unwrapFieldKey(input: {191 wrapped: string;192 recipientSecret: Uint8Array;193 senderPublic: Uint8Array;194 context: WrapContext;195}): Uint8Array {196 if (!input.wrapped.startsWith(WRAP_PREFIX)) {197 throw new SharedFieldError("wrap-invalid", "a wrapped key must carry the wrap1 prefix");198 }199 const bytes = fromBase64Url(input.wrapped.slice(WRAP_PREFIX.length));200 if (bytes.length <= KEY_LENGTH + NONCE_LENGTH) {201 throw new SharedFieldError("wrap-invalid", "a wrapped key is truncated");202 }203 const ephemeralPublic = bytes.slice(0, KEY_LENGTH);204 const nonce = bytes.slice(KEY_LENGTH, KEY_LENGTH + NONCE_LENGTH);205 const recipientPublic = sharedFieldPublicKey(input.recipientSecret);206 const key = wrapKeyMaterial(207 ephemeralPublic,208 input.senderPublic,209 recipientPublic,210 x25519.getSharedSecret(input.recipientSecret, ephemeralPublic),211 x25519.getSharedSecret(input.recipientSecret, input.senderPublic),212 );213 const cipher = xchacha20poly1305(key, nonce, wrapAssociatedData(input.context));214 try {215 const fieldKey = cipher.decrypt(bytes.slice(KEY_LENGTH + NONCE_LENGTH));216 if (fieldKey.length !== KEY_LENGTH) {217 throw new SharedFieldError("wrap-invalid", "a wrapped key opened to the wrong length");218 }219 return fieldKey;220 } catch (error) {221 if (error instanceof SharedFieldError) throw error;222 throw new SharedFieldError(223 "wrap-invalid",224 "a wrapped key failed authentication — forged sender, substituted key, replayed " +225 "context, or tampered bytes",226 );227 }228}229230/** The key scope a sealed shared value belongs to. */231export type SharedValueScope = {232 groupTable: string;233 groupId: string;234 generation: number;235};236237const SEALED_PREFIX = "encs1.";238const COLUMN_INFO = "lofi:shared-field:column:";239240function assertScopeSegment(kind: string, value: string): void {241 if (!value || value.includes(".")) {242 throw new SharedFieldError(243 "unscoped-write",244 `a shared-field ${kind} must be non-empty and must not contain "." (got "${value}")`,245 );246 }247}248249function columnKey(fieldKey: Uint8Array, label: string): Uint8Array {250 return hkdf(sha256, fieldKey, undefined, `${COLUMN_INFO}${label}`, KEY_LENGTH);251}252253function sealedAssociatedData(label: string, scope: SharedValueScope): Uint8Array {254 return textEncoder.encode(255 `lofi:shared-field:${label}|${scope.groupTable}|${scope.groupId}|${scope.generation}`,256 );257}258259/**260 * Seals a shared column value under a field key. The stored string carries261 * the scope and generation so the synchronous open path can pick the right262 * key from the keyring.263 */264export function sealSharedValue(input: {265 plaintext: string;266 fieldKey: Uint8Array;267 label: string;268 scope: SharedValueScope;269}): string {270 assertScopeSegment("group table", input.scope.groupTable);271 assertScopeSegment("group id", input.scope.groupId);272 if (!Number.isInteger(input.scope.generation) || input.scope.generation < 1) {273 throw new SharedFieldError(274 "unscoped-write",275 `a shared-field generation must be a positive integer (got ${input.scope.generation})`,276 );277 }278 const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));279 const cipher = xchacha20poly1305(280 columnKey(input.fieldKey, input.label),281 nonce,282 sealedAssociatedData(input.label, input.scope),283 );284 const ciphertext = cipher.encrypt(padPayload(textEncoder.encode(input.plaintext)));285 return `${SEALED_PREFIX}${input.scope.groupTable}.${input.scope.groupId}.` +286 `${input.scope.generation}.${toBase64Url(concatBytes(nonce, ciphertext))}`;287}288289/** The parsed shape of a stored `encs1.` value. */290export type ParsedSharedValue = {291 scope: SharedValueScope;292 payload: Uint8Array;293};294295/**296 * Parses a stored shared value into its scope and sealed payload, or returns297 * null when the value does not carry the shared-value prefix.298 */299export function parseSharedValue(stored: string): ParsedSharedValue | null {300 if (typeof stored !== "string" || !stored.startsWith(SEALED_PREFIX)) return null;301 const body = stored.slice(SEALED_PREFIX.length);302 const segments = body.split(".");303 if (segments.length !== 4) return null;304 const [groupTable, groupId, generationText, payloadText] = segments;305 const generation = Number(generationText);306 if (!groupTable || !groupId || !Number.isInteger(generation) || generation < 1) return null;307 return { scope: { groupTable, groupId, generation }, payload: fromBase64Url(payloadText) };308}309310/** Opens a sealed shared value with the field key its scope names. */311export function openSharedValue(input: {312 stored: string;313 fieldKey: Uint8Array;314 label: string;315}): string {316 const parsed = parseSharedValue(input.stored);317 if (parsed === null) {318 throw new SharedFieldError(319 "corrupt",320 `shared column "${input.label}" holds a value without the sealed-format prefix`,321 );322 }323 if (parsed.payload.length <= NONCE_LENGTH) {324 throw new SharedFieldError("corrupt", `shared column "${input.label}" holds a truncated value`);325 }326 const cipher = xchacha20poly1305(327 columnKey(input.fieldKey, input.label),328 parsed.payload.slice(0, NONCE_LENGTH),329 sealedAssociatedData(input.label, parsed.scope),330 );331 try {332 return textDecoder.decode(unpadPayload(cipher.decrypt(parsed.payload.slice(NONCE_LENGTH))));333 } catch {334 throw new SharedFieldError(335 "corrupt",336 `shared column "${input.label}" failed authentication — sealed under a different key, ` +337 "replayed from another column or scope, or tampered with",338 );339 }340}