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,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<typeof sodium> => {
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/<base64url(JSON)> 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}`,
};
};