/** * Crew Agent identity (Phase 0) * ───────────────────────────── * The Crew Agent ("🐝 Sasquatch") is a Nostr identity that belongs to a crew. * Its key is DETERMINISTICALLY DERIVED from the crew's addressable coordinate, * so any client can reproduce the same agent identity for a given crew without * storing a key anywhere. * * ⚠️ SECURITY TRADEOFF (documented, deliberate for v1): * Because the key is derived from public data (the crew coordinate) plus a * fixed, public salt in this source, ANYONE who reads this code can derive the * same private key. This is acceptable for v1 because the agent only ever: * - publishes its own kind-0 profile, and * - (later, Phase 3) posts helper replies into its own crew's chat. * It holds no funds and speaks only for itself. It is NOT a secure identity and * must never be trusted for signing anything sensitive. A future version can * swap this for a founder-controlled stored key without changing call sites. */ import { getPublicKey, finalizeEvent, nip19, type EventTemplate } from 'nostr-tools'; import { sha256 } from '@noble/hashes/sha2.js'; import { bytesToHex } from '@noble/hashes/utils.js'; import { SKATE_CREW_KIND } from '@/lib/types'; /** Public, fixed salt — versioned so we can rotate the scheme later if needed. */ const AGENT_KEY_SALT = 'skatespots:crew-agent:v1'; export interface CrewAgentIdentity { /** 32-byte private key (hex). */ secretKeyHex: string; /** 32-byte private key (bytes) — for signing. */ secretKey: Uint8Array; /** Public key (hex). */ pubkey: string; /** npub encoding of the public key. */ npub: string; } /** * Build the crew's addressable coordinate `kind:pubkey:identifier`. * This is the stable, public seed for the agent identity. */ export function crewCoord(crewPubkey: string, identifier: string): string { return `${SKATE_CREW_KIND}:${crewPubkey}:${identifier}`; } /** * Deterministically derive the crew agent's keypair from the crew coordinate. * Same crew → same agent identity, everywhere, with no stored state. */ export function deriveCrewAgentIdentity(crewPubkey: string, identifier: string): CrewAgentIdentity { const coord = crewCoord(crewPubkey, identifier); // 32-byte secret = sha256(salt | coord). secp256k1 accepts any 32-byte scalar // in range; sha256 output is overwhelmingly within range, and nostr-tools / // noble will reject the astronomically unlikely invalid case at sign time. const secretKey = sha256(new TextEncoder().encode(`${AGENT_KEY_SALT}|${coord}`)); const secretKeyHex = bytesToHex(secretKey); const pubkey = getPublicKey(secretKey); const npub = nip19.npubEncode(pubkey); return { secretKeyHex, secretKey, pubkey, npub }; } /** Just the agent pubkey — cheap when you don't need to sign. */ export function crewAgentPubkey(crewPubkey: string, identifier: string): string { return deriveCrewAgentIdentity(crewPubkey, identifier).pubkey; } /** Sasquatch mascot avatar used for the agent's profile. */ export const CREW_AGENT_AVATAR = 'https://npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx.blossom.band/2ff0cb5bd8622fed54a80784f5cc9d1f569714e9a16de65857a727e63404f5c2.png'; /** * Build (unsigned) the agent's kind-0 profile metadata for a crew. * `bot: true` follows NIP-24 so clients can honestly label it as automated. */ export function buildCrewAgentProfile(crewName: string): EventTemplate { const metadata = { name: `🐝 Sasquatch · ${crewName}`, display_name: `Sasquatch · ${crewName}`, about: `The AI crew guide for ${crewName} on SkateSpots. Ask me about spots, events, coaches, gear and more — I answer with live data. 🛹`, picture: CREW_AGENT_AVATAR, bot: true, }; return { kind: 0, content: JSON.stringify(metadata), tags: [], created_at: Math.floor(Date.now() / 1000), }; } /** * Sign an event template AS the crew agent (using its derived key). * Used to publish the agent's profile now, and its chat replies in Phase 3. */ export function signAsCrewAgent( crewPubkey: string, identifier: string, template: EventTemplate, ) { const { secretKey } = deriveCrewAgentIdentity(crewPubkey, identifier); return finalizeEvent(template, secretKey); }