fix(api): sign invites with stored owner keypair instead of unsigned placeholder
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled

Production /join on the broker (from feat 18c) rejects every invite
with invite_bad_signature because the web UI was emitting unsigned
payloads. This fixes that.

createMyMesh now generates ed25519 owner keypair + 32-byte root key
and stores all three on the mesh row. createMyInvite loads them,
signs the canonical invite bytes via crypto_sign_detached, and
emits a fully-signed payload matching what the broker expects:

  payload = {v, mesh_id, mesh_slug, broker_url, expires_at,
             mesh_root_key, role, owner_pubkey, signature}
  canonical = same fields minus signature, "|"-delimited
  signature = ed25519_sign(canonical, mesh.owner_secret_key)
  token = base64url(JSON(payload))   ← stored as invite.token

The base64url(JSON) token IS the DB lookup key — broker's /join
does `WHERE invite.token = <that string>`, then re-verifies the
signature it extracts from the decoded payload.

Also drops the sha256 derivePlaceholderRootKey() helper and the
encodeInviteLink helper, both replaced by inline logic.

backfill extended: the one-off script now populates owner_pubkey
AND owner_secret_key AND root_key together in a single pass. Query
condition is `WHERE any of the three IS NULL`, so running it
post-migration catches every row regardless of partial prior fills.

requires packages/api to depend on libsodium-wrappers + types
(added). 64/64 broker tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-04 23:12:04 +01:00
parent 1c773be577
commit 759a22e7c0
4 changed files with 177 additions and 58 deletions

View File

@@ -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): `<mesh_id> <mesh_slug> <owner_pubkey> <owner_secret_key>`
* 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:
* <mesh_id> <mesh_slug> <owner_pubkey> <owner_secret_key> <root_key>
*/
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<void> {
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<void> {
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()