feat(broker): invite signature verification + atomic one-time-use
Completes the v0.1.0 security model. Every /join is now gated by a
signed invite that the broker re-verifies against the mesh owner's
ed25519 pubkey, plus an atomic single-use counter.
schema (migrations/0001_demonic_karnak.sql):
- mesh.mesh.owner_pubkey: ed25519 hex of the invite signer
- mesh.invite.token_bytes: canonical signed bytes (for re-verification)
Both nullable; required for new meshes going forward.
canonical invite format (signed bytes):
`${v}|${mesh_id}|${mesh_slug}|${broker_url}|${expires_at}|
${mesh_root_key}|${role}|${owner_pubkey}`
wire format — invite payload in ic://join/<base64url(JSON)> now has:
owner_pubkey: "<64 hex>"
signature: "<128 hex>"
broker joinMesh() (apps/broker/src/broker.ts):
1. verify ed25519 signature over canonical bytes using payload's
owner_pubkey → else invite_bad_signature
2. load mesh, ensure mesh.owner_pubkey matches payload's owner_pubkey
→ else invite_owner_mismatch (prevents a malicious admin from
substituting their own owner key)
3. load invite row by token, verify mesh_id matches → else
invite_mesh_mismatch
4. expiry check → else invite_expired
5. revoked check → else invite_revoked
6. idempotency: if pubkey is already a member, return existing id
WITHOUT burning an invite use
7. atomic CAS: UPDATE used_count = used_count + 1 WHERE used_count <
max_uses → if 0 rows affected, return invite_exhausted
8. insert member with role from payload
cli side:
- apps/cli/src/invite/parse.ts: zod-validated owner_pubkey + signature
fields; client verifies signature immediately and rejects tampered
links (fail-fast before even touching the broker)
- buildSignedInvite() helper: owners sign invites client-side
- enrollWithBroker sends {invite_token, invite_payload, peer_pubkey,
display_name} (was: {mesh_id, peer_pubkey, display_name, role})
- parseInviteLink is now async (libsodium ready + verify)
seed-test-mesh.ts generates an owner keypair, sets mesh.owner_pubkey,
builds + signs an invite, stores the invite row, emits ownerPubkey +
ownerSecretKey + inviteToken + inviteLink in the output JSON.
tests — invite-signature.test.ts (9 new):
- valid signed invite → join succeeds
- tampered payload → invite_bad_signature
- signer not the mesh owner → invite_owner_mismatch
- expired invite → invite_expired
- revoked invite → invite_revoked
- exhausted (maxUses=2, 3rd join) → invite_exhausted
- idempotent re-join doesn't burn a use
- atomic single-use: 5 concurrent joins → exactly 1 success, 4 exhausted
- mesh_id payload vs DB row mismatch → invite_mesh_mismatch
verified live: tampered link blocked client-side with a clear error.
Unmodified link joins cleanly end-to-end (roundtrip.ts + join-roundtrip.ts
both pass). 64/64 tests green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
packages/db/migrations/0001_demonic_karnak.sql
Normal file
2
packages/db/migrations/0001_demonic_karnak.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "mesh"."invite" ADD COLUMN "token_bytes" text;--> statement-breakpoint
|
||||
ALTER TABLE "mesh"."mesh" ADD COLUMN "owner_pubkey" text;
|
||||
2821
packages/db/migrations/meta/0001_snapshot.json
Normal file
2821
packages/db/migrations/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
||||
"when": 1775336269295,
|
||||
"tag": "0000_living_namora",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1775339743477,
|
||||
"tag": "0001_demonic_karnak",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -82,6 +82,13 @@ export const mesh = meshSchema.table("mesh", {
|
||||
transport: meshTransportEnum().notNull().default("managed"),
|
||||
maxPeers: integer(),
|
||||
tier: meshTierEnum().notNull().default("free"),
|
||||
/**
|
||||
* ed25519 public key (hex) of the mesh owner / admin signer.
|
||||
* Invites are signed by the corresponding secret key and verified
|
||||
* by the broker on /join against this column. Nullable for existing
|
||||
* rows; required for new meshes.
|
||||
*/
|
||||
ownerPubkey: text(),
|
||||
createdAt: timestamp().defaultNow().notNull(),
|
||||
archivedAt: timestamp(),
|
||||
});
|
||||
@@ -116,6 +123,10 @@ export const meshMember = meshSchema.table("member", {
|
||||
|
||||
/**
|
||||
* Invite tokens used to join a mesh via shareable URL.
|
||||
*
|
||||
* `token` — opaque DB lookup key (the ic:// link's payload)
|
||||
* `tokenBytes` — canonical signed bytes that the broker re-verifies
|
||||
* against mesh.ownerPubkey on every /join call
|
||||
*/
|
||||
export const invite = meshSchema.table("invite", {
|
||||
id: text().primaryKey().notNull().$defaultFn(generateId),
|
||||
@@ -123,6 +134,7 @@ export const invite = meshSchema.table("invite", {
|
||||
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||
.notNull(),
|
||||
token: text().notNull().unique(),
|
||||
tokenBytes: text(),
|
||||
maxUses: integer().notNull().default(1),
|
||||
usedCount: integer().notNull().default(0),
|
||||
role: meshRoleEnum().notNull().default("member"),
|
||||
|
||||
Reference in New Issue
Block a user