clone

an rts arena for scripts: submit tribe strategies over the web or mcp, run deterministic matches, watch them

main
tribes / worker / test / keys.test.ts
keys.test.ts 7.4K
1/**2 * Keypair identity: proof verification, token minting, and shoo linking3 * (account merge) — real ed25519 via WebCrypto against the fake D1.4 */5import { describe, expect, it } from 'vitest';6import type { Env } from '../src/env';7import { createFakeD1, createFakeR2 } from './fake-d1';8import * as db from '../src/db';9import { keyAuth, linkByCode, linkKey, mintLinkCode, verifyKeyProof, type KeyProof } from '../src/keys';10import { authenticateBearer } from '../src/auth';11import { nowSec } from '../src/util';1213const HOST = 't.example';1415function makeEnv(): { env: Env; D: D1Database } {16 const D = createFakeD1();17 const env = { DB: D, BLOBS: createFakeR2().bucket, DEV_AUTH: '0' } as unknown as Env;18 return { env, D };19}2021interface TestKey {22 publicKey: string;23 sign(msg: string): Promise<string>;24}2526async function makeKey(): Promise<TestKey> {27 const kp = (await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify'])) as CryptoKeyPair;28 const raw = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey));29 const b64u = (b: Uint8Array): string =>30 Buffer.from(b).toString('base64url');31 return {32 publicKey: b64u(raw),33 async sign(msg: string): Promise<string> {34 const sig = await crypto.subtle.sign('Ed25519', kp.privateKey, new TextEncoder().encode(msg));35 return b64u(new Uint8Array(sig));36 },37 };38}3940async function proofFor(key: TestKey, host = HOST, ts = nowSec()): Promise<KeyProof> {41 return { publicKey: key.publicKey, ts, sig: await key.sign(`tribes-key-auth:${host}:${ts}`) };42}4344describe('verifyKeyProof', () => {45 it('accepts a fresh valid proof and rejects tampering', async () => {46 const key = await makeKey();47 const good = await proofFor(key);48 expect(await verifyKeyProof(good, HOST)).toBe(`key:${key.publicKey}`);49 // Wrong host in the signature.50 expect(await verifyKeyProof(await proofFor(key, 'evil.example'), HOST)).toBeNull();51 // Stale timestamp.52 expect(await verifyKeyProof(await proofFor(key, HOST, nowSec() - 3600), HOST)).toBeNull();53 // Signature from a different key.54 const other = await makeKey();55 const forged = { ...good, sig: (await proofFor(other)).sig };56 expect(await verifyKeyProof(forged, HOST)).toBeNull();57 // Garbage key material must not throw.58 expect(await verifyKeyProof({ publicKey: 'AA', ts: nowSec(), sig: good.sig }, HOST)).toBeNull();59 });60});6162describe('keyAuth', () => {63 it('mints a working bearer token; same key = same user across mints', async () => {64 const { env } = makeEnv();65 const key = await makeKey();66 const first = await keyAuth(env, await proofFor(key), HOST);67 expect(first).not.toBeNull();68 expect(first!.token.startsWith('trb_')).toBe(true);6970 const authed = await authenticateBearer(71 new Request('https://t.example/api/logic', {72 headers: { authorization: `Bearer ${first!.token}` },73 }),74 env,75 );76 expect(authed?.userId).toBe(first!.user.id);7778 const second = await keyAuth(env, await proofFor(key), HOST);79 expect(second!.user.id).toBe(first!.user.id); // stable identity80 expect(second!.token).not.toBe(first!.token); // fresh credential81 });8283 it('rejects an invalid proof', async () => {84 const { env } = makeEnv();85 const key = await makeKey();86 const bad = await proofFor(key, 'other.host');87 expect(await keyAuth(env, bad, HOST)).toBeNull();88 });89});9091describe('linkKey', () => {92 it('merges the keypair account into the signed-in user', async () => {93 const { env, D } = makeEnv();94 const key = await makeKey();95 // Key user accrues stuff first.96 const minted = await keyAuth(env, await proofFor(key), HOST);97 const keyUserId = minted!.user.id;98 // A shoo user signs in separately.99 const shoo = await db.upsertUser(D, 'shoo:abc', 'dami');100 const outcome = await linkKey(101 env,102 { userId: shoo.id, externalId: shoo.external_id },103 await proofFor(key),104 HOST,105 );106 expect(outcome).toBe('linked');107 // Key user is gone; its identity and tokens now resolve to the shoo user.108 expect(await db.getUserById(D, keyUserId)).toBeNull();109 const viaKey = await keyAuth(env, await proofFor(key), HOST);110 expect(viaKey!.user.id).toBe(shoo.id);111 // One token per device key: the re-mint above REPLACED the original.112 const oldAuthed = await authenticateBearer(113 new Request('https://t.example/x', {114 headers: { authorization: `Bearer ${minted!.token}` },115 }),116 env,117 );118 expect(oldAuthed).toBeNull();119 const newAuthed = await authenticateBearer(120 new Request('https://t.example/x', {121 headers: { authorization: `Bearer ${viaKey!.token}` },122 }),123 env,124 );125 expect(newAuthed?.userId).toBe(shoo.id);126127 // Idempotent relink.128 expect(129 await linkKey(env, { userId: shoo.id, externalId: shoo.external_id }, await proofFor(key), HOST),130 ).toBe('already');131 });132});133134describe('link codes (device pairing)', () => {135 it('mint -> redeem merges the device account; codes are single-use', async () => {136 const { env, D } = makeEnv();137 const key = await makeKey();138 const device = await keyAuth(env, await proofFor(key), HOST);139 const site = await db.upsertUser(D, 'shoo:site', 'dami');140141 const { code } = await mintLinkCode(env, site.id);142 const caller = { userId: device!.user.id, externalId: device!.user.external_id };143 expect(await linkByCode(env, caller, code)).toBe('linked');144 // Device account merged away; key now resolves to the site user.145 expect(await db.getUserById(D, device!.user.id)).toBeNull();146 const again = await keyAuth(env, await proofFor(key), HOST);147 expect(again!.user.id).toBe(site.id);148 // Single use.149 expect(await linkByCode(env, caller, code)).toBe('invalid');150 });151152 it('rejects unknown and self-redeemed codes sanely', async () => {153 const { env, D } = makeEnv();154 const site = await db.upsertUser(D, 'shoo:solo', null);155 const me = { userId: site.id, externalId: site.external_id };156 expect(await linkByCode(env, me, 'wrong-code-0000')).toBe('invalid');157 const { code } = await mintLinkCode(env, site.id);158 expect(await linkByCode(env, me, code)).toBe('already'); // own code: no-op159 });160161 it('a new mint replaces the outstanding code', async () => {162 const { env, D } = makeEnv();163 const site = await db.upsertUser(D, 'shoo:rotate', null);164 const key = await makeKey();165 const device = await keyAuth(env, await proofFor(key), HOST);166 const caller = { userId: device!.user.id, externalId: device!.user.external_id };167 const first = await mintLinkCode(env, site.id);168 const second = await mintLinkCode(env, site.id);169 expect(await linkByCode(env, caller, first.code)).toBe('invalid');170 expect(await linkByCode(env, caller, second.code)).toBe('linked');171 });172});173174describe('merge preserves sharing', () => {175 it('a public portfolio stays listed when merged into a non-sharing account', async () => {176 const { env, D } = makeEnv();177 const key = await makeKey();178 const device = await keyAuth(env, await proofFor(key), HOST);179 await db.setUserSharing(D, device!.user.id, true);180 const site = await db.upsertUser(D, 'shoo:nosharing', null); // public_sharing 0181 const { code } = await mintLinkCode(env, site.id);182 await linkByCode(env, { userId: device!.user.id, externalId: device!.user.external_id }, code);183 const merged = await db.getUserById(D, site.id);184 expect(merged!.public_sharing).toBe(1);185 });186});