diff --git a/apps/broker/scripts/backfill-owner-pubkey.ts b/apps/broker/scripts/backfill-owner-pubkey.ts index 355b28a..a7a4449 100644 --- a/apps/broker/scripts/backfill-owner-pubkey.ts +++ b/apps/broker/scripts/backfill-owner-pubkey.ts @@ -1,23 +1,22 @@ #!/usr/bin/env bun /** - * One-off backfill: populate `mesh.mesh.owner_pubkey` for meshes - * created before Step 18c landed. + * One-off backfill: populate owner_pubkey + owner_secret_key + + * root_key for meshes created before Step 18c crypto landed. * - * Runs idempotently: only touches rows where owner_pubkey IS NULL. - * Generates a fresh ed25519 keypair per mesh and writes the owner - * SECRET KEY to stdout (paired with mesh_id) so an operator can - * hand it back to the mesh owner out-of-band. + * Runs idempotently: only touches rows where ANY of those three + * columns is NULL. Generates a fresh keypair + root key per mesh + * and stores ALL THREE server-side (invites are signed server-side + * by the web UI's create-invite flow, so it needs the secret key). * * Usage: * DATABASE_URL=... bun apps/broker/scripts/backfill-owner-pubkey.ts * - * Output format (per row): ` ` - * Redirect stdout to a secure file — the secret keys grant admin - * invite-signing power and must be stored carefully. + * Output (stdout): one tab-separated row per patched mesh: + * */ import sodium from "libsodium-wrappers"; -import { eq, isNull } from "drizzle-orm"; +import { eq, isNull, or } from "drizzle-orm"; import { db } from "../src/db"; import { mesh } from "@turbostarter/db/schema/mesh"; @@ -25,9 +24,21 @@ async function main(): Promise { await sodium.ready; const missing = await db - .select({ id: mesh.id, slug: mesh.slug, name: mesh.name }) + .select({ + id: mesh.id, + slug: mesh.slug, + ownerPubkey: mesh.ownerPubkey, + ownerSecretKey: mesh.ownerSecretKey, + rootKey: mesh.rootKey, + }) .from(mesh) - .where(isNull(mesh.ownerPubkey)); + .where( + or( + isNull(mesh.ownerPubkey), + isNull(mesh.ownerSecretKey), + isNull(mesh.rootKey), + )!, + ); if (missing.length === 0) { console.error("[backfill] no rows to patch"); @@ -39,19 +50,24 @@ async function main(): Promise { const kp = sodium.crypto_sign_keypair(); const pubHex = sodium.to_hex(kp.publicKey); const secHex = sodium.to_hex(kp.privateKey); + const rootKey = sodium.to_base64( + sodium.randombytes_buf(32), + sodium.base64_variants.URLSAFE_NO_PADDING, + ); await db .update(mesh) - .set({ ownerPubkey: pubHex }) + .set({ + ownerPubkey: pubHex, + ownerSecretKey: secHex, + rootKey, + }) .where(eq(mesh.id, row.id)); - // stdout: machine-readable, one mesh per line - console.log(`${row.id}\t${row.slug}\t${pubHex}\t${secHex}`); - console.error( - `[backfill] patched mesh "${row.slug}" (${row.id}) — save its secret key`, + console.log( + `${row.id}\t${row.slug}\t${pubHex}\t${secHex}\t${rootKey}`, ); + console.error(`[backfill] patched mesh "${row.slug}" (${row.id})`); } - console.error( - "[backfill] done. SECURELY HAND OFF secret keys to mesh owners.", - ); + console.error("[backfill] done."); } main() diff --git a/packages/api/package.json b/packages/api/package.json index df16523..5db2fd8 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -32,6 +32,7 @@ "ai": "catalog:", "envin": "catalog:", "hono": "4.10.4", + "libsodium-wrappers": "0.7.15", "zod": "catalog:" }, "devDependencies": { @@ -39,6 +40,7 @@ "@turbostarter/prettier-config": "workspace:*", "@turbostarter/tsconfig": "workspace:*", "@turbostarter/vitest-config": "workspace:*", + "@types/libsodium-wrappers": "0.7.14", "eslint": "catalog:", "prettier": "catalog:", "typescript": "catalog:", diff --git a/packages/api/src/modules/mesh/mutations.ts b/packages/api/src/modules/mesh/mutations.ts index 2e7c74b..88b327b 100644 --- a/packages/api/src/modules/mesh/mutations.ts +++ b/packages/api/src/modules/mesh/mutations.ts @@ -1,4 +1,4 @@ -import { randomBytes, createHash } from "node:crypto"; +import sodium from "libsodium-wrappers"; import { and, eq, isNull } from "@turbostarter/db"; import { invite, mesh, meshMember } from "@turbostarter/db/schema"; @@ -11,6 +11,32 @@ import type { const BROKER_URL = process.env.NEXT_PUBLIC_BROKER_URL ?? "ws://localhost:7900"; +/** + * Canonical invite bytes — MUST match the broker's canonicalInvite() + * in apps/broker/src/crypto.ts exactly. Any delimiter/field change + * between signer and verifier produces `invite_bad_signature`. + */ +const canonicalInvite = (p: { + v: number; + mesh_id: string; + mesh_slug: string; + broker_url: string; + expires_at: number; + mesh_root_key: string; + role: "admin" | "member"; + owner_pubkey: string; +}): string => + `${p.v}|${p.mesh_id}|${p.mesh_slug}|${p.broker_url}|${p.expires_at}|${p.mesh_root_key}|${p.role}|${p.owner_pubkey}`; + +let sodiumReady = false; +const ensureSodium = async (): Promise => { + if (!sodiumReady) { + await sodium.ready; + sodiumReady = true; + } + return sodium; +}; + export const createMyMesh = async ({ userId, input, @@ -29,6 +55,18 @@ export const createMyMesh = async ({ throw new Error("A mesh with that slug already exists."); } + // Generate the mesh owner's ed25519 keypair (signs invites) and a + // 32-byte shared root key (channel encryption in later steps). + // See mesh.ownerSecretKey comment re: plaintext-at-rest trade-off. + const s = await ensureSodium(); + const kp = s.crypto_sign_keypair(); + const ownerPubkey = s.to_hex(kp.publicKey); + const ownerSecretKey = s.to_hex(kp.privateKey); + const rootKey = s.to_base64( + s.randombytes_buf(32), + s.base64_variants.URLSAFE_NO_PADDING, + ); + const [created] = await db .insert(mesh) .values({ @@ -37,6 +75,9 @@ export const createMyMesh = async ({ visibility: input.visibility, transport: input.transport, ownerUserId: userId, + ownerPubkey, + ownerSecretKey, + rootKey, }) .returning({ id: mesh.id, slug: mesh.slug }); @@ -87,20 +128,6 @@ export const leaveMyMesh = async ({ return updated; }; -/** Encode an ic://join/ invite link. Format mirrors - * apps/cli/src/invite/parse.ts exactly. */ -const encodeInviteLink = (payload: unknown): string => { - const json = JSON.stringify(payload); - const encoded = Buffer.from(json, "utf-8").toString("base64url"); - return `ic://join/${encoded}`; -}; - -/** Placeholder deterministic root key until mesh_root_key column lands - * (Step 18 crypto). Signature verification is Step 18, so an actual - * ed25519 pubkey is not yet required — only presence is checked. */ -const derivePlaceholderRootKey = (meshId: string, meshSlug: string): string => - createHash("sha256").update(`${meshId}:${meshSlug}`).digest("hex"); - export const createMyInvite = async ({ userId, meshId, @@ -110,12 +137,15 @@ export const createMyInvite = async ({ meshId: string; input: CreateMyInviteInput; }) => { - // Authz: owner or admin member can invite + // Authz: owner or admin member can invite. const [meshRow] = await db .select({ id: mesh.id, slug: mesh.slug, ownerUserId: mesh.ownerUserId, + ownerPubkey: mesh.ownerPubkey, + ownerSecretKey: mesh.ownerSecretKey, + rootKey: mesh.rootKey, }) .from(mesh) .where(eq(mesh.id, meshId)) @@ -124,6 +154,15 @@ export const createMyInvite = async ({ if (!meshRow) { throw new Error("Mesh not found."); } + if ( + !meshRow.ownerPubkey || + !meshRow.ownerSecretKey || + !meshRow.rootKey + ) { + throw new Error( + "Mesh is missing owner keypair or root key — run backfill script.", + ); + } const isOwner = meshRow.ownerUserId === userId; if (!isOwner) { @@ -143,38 +182,59 @@ export const createMyInvite = async ({ } } - const token = randomBytes(24).toString("base64url"); const expiresAt = new Date( Date.now() + input.expiresInDays * 24 * 60 * 60 * 1000, ); + const expiresAtSec = Math.floor(expiresAt.getTime() / 1000); + // Build the canonical signed payload. Signature covers every field + // except `signature` itself; broker re-verifies identically. + const payloadCore = { + v: 1 as const, + mesh_id: meshRow.id, + mesh_slug: meshRow.slug, + broker_url: BROKER_URL, + expires_at: expiresAtSec, + mesh_root_key: meshRow.rootKey, + role: input.role, + owner_pubkey: meshRow.ownerPubkey, + }; + const canonical = canonicalInvite(payloadCore); + const s = await ensureSodium(); + const signature = s.to_hex( + s.crypto_sign_detached( + s.from_string(canonical), + s.from_hex(meshRow.ownerSecretKey), + ), + ); + const fullPayload = { ...payloadCore, signature }; + + // The base64url(JSON) is BOTH the link payload AND the DB lookup + // token — broker's /join resolves invites by this string. + const token = Buffer.from(JSON.stringify(fullPayload), "utf-8").toString( + "base64url", + ); const [created] = await db .insert(invite) .values({ meshId, token, + tokenBytes: canonical, maxUses: input.maxUses, role: input.role, expiresAt, createdBy: userId, }) - .returning({ id: invite.id, token: invite.token, expiresAt: invite.expiresAt }); - - const payload = { - v: 1 as const, - mesh_id: meshRow.id, - mesh_slug: meshRow.slug, - broker_url: BROKER_URL, - expires_at: Math.floor(expiresAt.getTime() / 1000), - mesh_root_key: derivePlaceholderRootKey(meshRow.id, meshRow.slug), - role: input.role, - // signature: added in Step 18 (ed25519 sign by mesh_root_key) - }; + .returning({ + id: invite.id, + token: invite.token, + expiresAt: invite.expiresAt, + }); return { id: created!.id, token: created!.token, expiresAt: created!.expiresAt, - inviteLink: encodeInviteLink(payload), + inviteLink: `ic://join/${token}`, }; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4881c0c..5304ea6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -302,6 +302,9 @@ importers: pdfjs-dist: specifier: 5.4.530 version: 5.4.530 + qrcode: + specifier: 1.5.4 + version: 1.5.4 react: specifier: catalog:react19 version: 19.1.0 @@ -363,6 +366,9 @@ importers: '@types/node': specifier: catalog:node22 version: 22.16.0 + '@types/qrcode': + specifier: 1.5.6 + version: 1.5.6 '@types/react': specifier: 19.2.7 version: 19.2.7 @@ -545,6 +551,9 @@ importers: hono: specifier: 4.10.4 version: 4.10.4 + libsodium-wrappers: + specifier: 0.7.15 + version: 0.7.15 zod: specifier: 'catalog:' version: 4.1.13 @@ -561,6 +570,9 @@ importers: '@turbostarter/vitest-config': specifier: workspace:* version: link:../../tooling/vitest + '@types/libsodium-wrappers': + specifier: 0.7.14 + version: 0.7.14 eslint: specifier: 'catalog:' version: 9.39.0(jiti@2.6.1) @@ -7046,6 +7058,9 @@ packages: '@types/prismjs@1.26.5': resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.0.4': resolution: {integrity: sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==} peerDependencies: @@ -8424,6 +8439,9 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -11371,6 +11389,10 @@ packages: resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} engines: {node: '>=4.0.0'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -11654,6 +11676,11 @@ packages: resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==} hasBin: true + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} @@ -15888,7 +15915,7 @@ snapshots: ci-info: 3.9.0 compression: 1.8.0 connect: 3.7.0 - debug: 4.4.1 + debug: 4.4.3 env-editor: 0.4.2 expo: 54.0.27(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.17)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3))(react@19.2.3))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) expo-server: 1.0.5 @@ -15945,7 +15972,7 @@ snapshots: '@expo/plist': 0.4.8 '@expo/sdk-runtime-versions': 1.0.0 chalk: 4.1.2 - debug: 4.4.1 + debug: 4.4.3 getenv: 2.0.0 glob: 13.0.0 resolve-from: 5.0.0 @@ -15999,7 +16026,7 @@ snapshots: '@expo/env@2.0.8': dependencies: chalk: 4.1.2 - debug: 4.4.1 + debug: 4.4.3 dotenv: 16.4.7 dotenv-expand: 11.0.7 getenv: 2.0.0 @@ -16012,7 +16039,7 @@ snapshots: '@expo/spawn-async': 1.7.2 arg: 5.0.2 chalk: 4.1.2 - debug: 4.4.1 + debug: 4.4.3 getenv: 2.0.0 glob: 13.0.0 ignore: 5.3.2 @@ -16056,7 +16083,7 @@ snapshots: '@expo/spawn-async': 1.7.2 browserslist: 4.25.1 chalk: 4.1.2 - debug: 4.4.1 + debug: 4.4.3 dotenv: 16.4.7 dotenv-expand: 11.0.7 getenv: 2.0.0 @@ -16139,7 +16166,7 @@ snapshots: '@expo/image-utils': 0.8.8 '@expo/json-file': 10.0.8 '@react-native/normalize-colors': 0.81.5 - debug: 4.4.1 + debug: 4.4.3 expo: 54.0.27(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.17)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3))(react@19.2.3))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) resolve-from: 5.0.0 semver: 7.7.2 @@ -20534,6 +20561,10 @@ snapshots: '@types/prismjs@1.26.5': {} + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 24.0.13 + '@types/react-dom@19.0.4(@types/react@19.2.7)': dependencies: '@types/react': 19.2.7 @@ -21305,7 +21336,7 @@ snapshots: babel-plugin-react-native-web: 0.21.1 babel-plugin-syntax-hermes-parser: 0.29.1 babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.28.5) - debug: 4.4.1 + debug: 4.4.3 react-refresh: 0.14.2 resolve-from: 5.0.0 optionalDependencies: @@ -22060,6 +22091,8 @@ snapshots: diff@4.0.2: {} + dijkstrajs@1.0.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -22850,7 +22883,7 @@ snapshots: '@react-navigation/native': 7.1.14(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) '@react-navigation/native-stack': 7.3.21(@react-navigation/native@7.1.14(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3))(react@19.2.3))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3))(react@19.2.3))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3))(react@19.2.3))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) client-only: 0.0.1 - debug: 4.4.1 + debug: 4.4.3 escape-string-regexp: 4.0.0 expo: 54.0.27(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.17)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3))(react@19.2.3))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) expo-constants: 18.0.11(expo@54.0.27)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.2.3)) @@ -25815,6 +25848,8 @@ snapshots: pngjs@3.4.0: optional: true + pngjs@5.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-import@15.1.0(postcss@8.5.6): @@ -26028,6 +26063,12 @@ snapshots: qrcode-terminal@0.11.0: optional: true + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.14.0: dependencies: side-channel: 1.1.0