# canary-kit — Full API Reference > Deepfake-proof spoken-word identity verification. Open protocol, minimal dependencies. Minimal dependencies. ESM-only. TypeScript native. Repository: https://github.com/forgesworn/canary-kit Interactive demo: https://canary.trotters.cc/ Licence: MIT ## Install ``` npm install canary-kit ``` ## Imports ```typescript // Barrel (main API — group management, token protocol, beacon, wordlist) import { createGroup, getCurrentWord, getCurrentDuressWord, advanceCounter, reseed, addMember, removeMember, syncCounter, deriveToken, deriveDuressToken, verifyToken, deriveLivenessToken, encodeAsWords, encodeAsPin, encodeAsHex, encodeToken, deriveVerificationWord, deriveVerificationPhrase, deriveDuressWord, deriveDuressPhrase, verifyWord, PRESETS, deriveBeaconKey, encryptBeacon, decryptBeacon, buildDuressAlert, encryptDuressAlert, decryptDuressAlert, getCounter, counterToBytes, WORDLIST, WORDLIST_SIZE, getWord, indexOf, } from 'canary-kit' // Subpath imports (tree-shakeable) import { deriveToken, deriveDuressToken, verifyToken, deriveLivenessToken, deriveDirectionalPair } from 'canary-kit/token' import { encodeAsWords, encodeAsPin, encodeAsHex, encodeToken } from 'canary-kit/encoding' import { createSession, generateSeed, deriveSeed, SESSION_PRESETS } from 'canary-kit/session' import { WORDLIST, WORDLIST_SIZE, getWord, indexOf } from 'canary-kit/wordlist' import { KINDS, buildGroupStateEvent, buildStoredSignalEvent, buildSignalEvent, buildRumourEvent, hashGroupId } from 'canary-kit/nostr' import { deriveBeaconKey, encryptBeacon, decryptBeacon, buildDuressAlert, encryptDuressAlert, decryptDuressAlert } from 'canary-kit/beacon' import { applySyncMessage, applySyncMessageWithResult, decodeSyncMessage, encodeSyncMessage, deriveGroupKey, deriveGroupIdentity, hashGroupTag, encryptEnvelope, decryptEnvelope, PROTOCOL_VERSION, type SyncApplyResult, type SyncTransport, type EventSigner } from 'canary-kit/sync' ``` --- ## Types ```typescript // ── Encoding ───────────────────────────────────────────────────────────────── /** Controls how a token is encoded for spoken/displayed output. */ type TokenEncoding = | { format: 'words'; count?: number; wordlist?: readonly string[] } | { format: 'pin'; digits?: number } | { format: 'hex'; length?: number } /** Default encoding: a single word from the en-v1 wordlist. */ const DEFAULT_ENCODING: TokenEncoding // { format: 'words', count: 1 } /** Maximum allowed tolerance/maxTolerance value. Prevents pathological iteration. */ const MAX_TOLERANCE = 10 // ── Token Protocol ──────────────────────────────────────────────────────────── /** Result of verifying a token against a group's secret and identities. */ interface TokenVerifyResult { /** 'valid' = matches normal token, 'duress' = matches a duress token, 'invalid' = no match. */ status: 'valid' | 'duress' | 'invalid' /** Identities of duress signallers (only present when status = 'duress'). */ identities?: string[] } /** Options for token verification. */ interface VerifyOptions { /** Output encoding to use for comparison (default: single word). */ encoding?: TokenEncoding /** Counter tolerance window: accept tokens within ±tolerance counter values (default: 0). */ tolerance?: number } /** A pair of directional tokens keyed by role name. */ interface DirectionalPair { [role: string]: string } // ── Group Verification ──────────────────────────────────────────────────────── /** Outcome of group word verification. */ type VerifyStatus = 'verified' | 'duress' | 'stale' | 'failed' /** Result of verifying a spoken word against a group. */ interface VerifyResult { status: VerifyStatus /** Pubkeys of members whose duress word matched (only when status = 'duress'). */ members?: string[] } // ── Group Management ────────────────────────────────────────────────────────── /** Configuration for creating a new group. */ interface GroupConfig { name: string members: string[] // Nostr pubkeys (64-char hex) preset?: PresetName // Named threat-profile preset rotationInterval?: number // Seconds; overrides preset value wordCount?: 1 | 2 | 3 // Words per challenge; overrides preset value wordlist?: string // Wordlist identifier (default: 'en-v1') tolerance?: number // Counter tolerance for verification (default: 1) beaconInterval?: number // Beacon broadcast interval in seconds (default: 300) beaconPrecision?: number // Geohash precision for normal beacons 1–11 (default: 6) creator?: string // Pubkey of group creator — only the creator is admin at bootstrap. Must be in members. Without creator, admins is empty and all privileged sync ops are silently rejected. } /** Serialisable persistent state for a canary group. */ interface GroupState { name: string seed: string // 64-char hex (256-bit shared secret) members: string[] // Nostr pubkeys (64-char hex) admins: string[] // Pubkeys with admin privileges (reseed, add/remove others). Set via GroupConfig.creator. rotationInterval: number // Seconds wordCount: 1 | 2 | 3 wordlist: string // e.g. 'en-v1' counter: number // Time-based counter at last sync usageOffset: number // Burn-after-use offset on top of counter tolerance: number // Counter tolerance for verification createdAt: number // Unix timestamp beaconInterval: number // Seconds beaconPrecision: number // 1–11 epoch: number // Monotonic epoch — increments on reseed. Used for replay protection. consumedOps: string[] // Consumed operation IDs within current epoch. Cleared on epoch bump. consumedOpsFloor?: number // Timestamp floor: reject messages at or below this value (replay protection after consumedOps eviction) } // ── Group Presets ───────────────────────────────────────────────────────────── /** Named threat-profile preset identifier. */ type PresetName = 'family' | 'field-ops' | 'enterprise' /** A threat-profile preset for group creation. */ interface GroupPreset { wordCount: 1 | 2 | 3 rotationInterval: number description: string } // ── Session (two-party directional verification) ────────────────────────────── /** Named session preset identifier. */ type SessionPresetName = 'call' | 'handoff' /** A session preset for directional two-party verification. */ interface SessionPreset { wordCount: number rotationSeconds: number // 0 = fixed counter (single-use) tolerance: number directional: boolean description: string } /** Configuration for creating a directional verification session. */ interface SessionConfig { secret: Uint8Array | string // Shared secret (hex string or raw bytes) namespace: string // Context namespace (e.g. 'aviva', 'dispatch') roles: [string, string] // The two roles (e.g. ['caller', 'agent']) myRole: string // Which role I am rotationSeconds?: number // Default: 30 encoding?: TokenEncoding tolerance?: number // Default: from preset or 0 theirIdentity?: string // Their identity string for duress detection preset?: SessionPresetName counter?: number // Fixed counter for single-use mode (rotationSeconds=0) } /** A role-aware, time-managed session for two-party verification. */ interface Session { counter(nowSec?: number): number myToken(nowSec?: number): string theirToken(nowSec?: number): string verify(spoken: string, nowSec?: number): TokenVerifyResult pair(nowSec?: number): DirectionalPair } // ── Beacon ──────────────────────────────────────────────────────────────────── /** Decrypted content of an encrypted location beacon (kind 20078 signal). */ interface BeaconPayload { geohash: string precision: number timestamp: number } /** Decrypted content of a duress alert beacon. */ interface DuressAlert { type: 'duress' member: string // Signalling member's pubkey geohash: string precision: number locationSource: 'beacon' | 'verifier' | 'none' timestamp: number } /** Location info supplied when building a duress alert. Null means no location. */ interface DuressLocation { geohash: string precision: number locationSource: 'beacon' | 'verifier' } // ── Nostr ───────────────────────────────────────────────────────────────────── /** Unsigned Nostr event (consumer signs with their own library). */ interface UnsignedEvent { kind: number content: string tags: string[][] created_at: number } interface GroupEventParams { groupId: string name: string members: string[] rotationInterval: number wordCount: 1 | 2 | 3 wordlist: string encryptedContent: string expiration?: number } interface SeedDistributionParams { recipientPubkey: string groupEventId: string encryptedContent: string } interface MemberUpdateParams { groupId: string action: 'add' | 'remove' memberPubkey: string reseed: boolean encryptedContent: string } interface ReseedParams { groupEventId: string reason: 'member_removed' | 'compromise' | 'scheduled' | 'duress' encryptedContent: string } interface WordUsedParams { groupEventId: string encryptedContent: string } interface BeaconEventParams { groupId: string encryptedContent: string expiration?: number } ``` --- ## canary-kit (main) The barrel export. Covers group management, the CANARY protocol token API, encoding, wordlist, counter, and beacon functions. Omits session-specific exports (`createSession`, `generateSeed`, `deriveSeed`, `SESSION_PRESETS`) — import those from `canary-kit/session`. ### deriveVerificationWord(seedHex, counter) Derive the single verification word shared by all group members for a given counter. ```typescript deriveVerificationWord(seedHex: string, counter: number): string deriveVerificationWord( 'a'.repeat(64), // 64-char hex seed 42, // current counter ) // e.g. 'marble' ``` Algorithm: `HMAC-SHA256(seed, counterToBytes(counter))`. First two bytes of digest `% 2048` → wordlist index. ### deriveVerificationPhrase(seedHex, counter, wordCount) Derive a multi-word verification phrase. Each word is derived from a consecutive 2-byte slice of the HMAC-SHA256 digest. ```typescript deriveVerificationPhrase( seedHex: string, counter: number, wordCount: 1 | 2 | 3, ): string[] deriveVerificationPhrase('a'.repeat(64), 42, 2) // e.g. ['marble', 'lantern'] ``` ### deriveDuressWord(seedHex, memberPubkeyHex, counter) Derive a member's duress word — unique per member, derivable by all group members, guaranteed distinct from the current verification word (and adjacent counters). Collision avoidance retries up to 255 times with incrementing suffix bytes. ```typescript deriveDuressWord( seedHex: string, memberPubkeyHex: string, // 64-char hex Nostr pubkey counter: number, ): string deriveDuressWord('a'.repeat(64), 'b'.repeat(64), 42) // e.g. 'crystal' (always ≠ current verification word) ``` ### deriveDuressPhrase(seedHex, memberPubkeyHex, counter, wordCount) Derive a multi-word duress phrase for a given member. The entire phrase is guaranteed distinct from the verification phrase within the ±1 tolerance window. ```typescript deriveDuressPhrase( seedHex: string, memberPubkeyHex: string, counter: number, wordCount: 1 | 2 | 3, ): string[] deriveDuressPhrase('a'.repeat(64), 'b'.repeat(64), 42, 2) // e.g. ['crystal', 'anchor'] ``` ### verifyWord(spokenWord, seedHex, memberPubkeys, counter, wordCount?) Verify a spoken word (or phrase) against the group's current state. Returns a structured result indicating whether verification succeeded, duress was signalled, the token was stale, or no match was found. Priority order: 1. Current verification word → `verified` 2. ALL members' duress words at current counter → `duress` (all matching members collected) 3. ALL members' duress words at previous counter → `duress` (stale but still duress) 4. Previous window's verification word → `stale` 5. No match → `failed` ```typescript verifyWord( spokenWord: string, seedHex: string, memberPubkeys: string[], counter: number, wordCount: 1 | 2 | 3 = 1, ): VerifyResult verifyWord('marble', seed, [alicePubkey, bobPubkey], 42) // { status: 'verified' } verifyWord('crystal', seed, [alicePubkey, bobPubkey], 42) // { status: 'duress', members: [''] } verifyWord('unknown', seed, [alicePubkey], 42) // { status: 'failed' } ``` ### createGroup(config) Create a new group with a freshly generated seed and time-based counter. ```typescript createGroup(config: GroupConfig): GroupState const state = createGroup({ name: 'Alpine Team', members: ['', ''], preset: 'field-ops', }) // With explicit options const state = createGroup({ name: 'Family', members: [''], rotationInterval: 604_800, // 7 days wordCount: 1, }) ``` ### getCurrentWord(state) Return the current verification word (or space-joined phrase) for all group members. ```typescript getCurrentWord(state: GroupState): string getCurrentWord(state) // e.g. 'marble' or 'marble lantern' (if wordCount=2) ``` ### getCurrentDuressWord(state, memberPubkey) Return the duress word (or phrase) for a specific member at the current counter. ```typescript getCurrentDuressWord(state: GroupState, memberPubkey: string): string getCurrentDuressWord(state, '') // e.g. 'crystal' ``` ### advanceCounter(state) Advance the usage offset by one, rotating the current word (burn-after-use). Returns new state — does not mutate. ```typescript advanceCounter(state: GroupState): GroupState const next = advanceCounter(state) getCurrentWord(next) // different word from getCurrentWord(state) ``` ### reseed(state) Generate a fresh seed and reset the usage offset. Call after a suspected security event. Returns new state — does not mutate. ```typescript reseed(state: GroupState): GroupState const safe = reseed(state) // safe.seed is a new random 64-char hex string // safe.usageOffset === 0 ``` ### addMember(state, pubkey) Add a member to the group. Idempotent — returns existing state if pubkey already present. Returns new state — does not mutate. ```typescript addMember(state: GroupState, pubkey: string): GroupState const updated = addMember(state, '') ``` ### removeMember(state, pubkey) Remove a member from the group's member list. Does NOT reseed — the removed member still possesses the old seed. Use `removeMemberAndReseed()` instead unless you have a specific reason not to. Returns new state — does not mutate. ```typescript removeMember(state: GroupState, pubkey: string): GroupState const updated = removeMember(state, '') // updated.seed === state.seed (NOT reseeded — old seed still valid) // updated.members does not include alice ``` ### removeMemberAndReseed(state, pubkey) Remove a member and immediately reseed, atomically invalidating the old seed. This is the recommended way to remove members — ensures forward secrecy by preventing the removed member from deriving future tokens or decrypting future beacons. Returns new state — does not mutate. ```typescript removeMemberAndReseed(state: GroupState, pubkey: string): GroupState const updated = removeMemberAndReseed(state, '') // updated.seed !== state.seed (reseeded) // updated.members does not include alice ``` ### dissolveGroup(state) Dissolve a group, zeroing the seed and clearing all members. Preserves name and timestamps for audit trail. Callers MUST also delete the persisted record (IndexedDB, localStorage, backend) after calling this. Returns new state — does not mutate. ```typescript dissolveGroup(state: GroupState): GroupState const dissolved = dissolveGroup(state) // dissolved.seed === '0'.repeat(64) // dissolved.members === [] // dissolved.admins === [] ``` ### syncCounter(state, nowSec?) Refresh the counter to the current time window. Call after loading persisted state. Returns new state — does not mutate. ```typescript syncCounter( state: GroupState, nowSec: number = Math.floor(Date.now() / 1000), ): GroupState const current = syncCounter(state) // current.counter is up to date with the current time window ``` ### deriveToken(secret, context, counter, encoding?) CANARY-DERIVE: Derive an encoded token string from a shared secret, context string, and counter. Algorithm: `HMAC-SHA256(secret, utf8(context) || counter_be32)`, then encoded per the `encoding` option. ```typescript deriveToken( secret: Uint8Array | string, // hex string or raw bytes context: string, // application-specific context string counter: number, // 32-bit unsigned integer encoding: TokenEncoding = DEFAULT_ENCODING, ): string deriveToken(secret, 'aviva:caller', 42) // e.g. 'marble' (single word, default encoding) deriveToken(secret, 'aviva:caller', 42, { format: 'pin', digits: 6 }) // e.g. '047291' deriveToken(secret, 'aviva:caller', 42, { format: 'hex', length: 16 }) // e.g. 'a3f2c1d4e5b60789' ``` ### deriveTokenBytes(secret, context, counter) CANARY-DERIVE: Derive raw 32-byte token bytes without encoding. ```typescript deriveTokenBytes( secret: Uint8Array | string, context: string, counter: number, ): Uint8Array const bytes = deriveTokenBytes(secret, 'aviva:caller', 42) // Uint8Array(32) [...] ``` ### deriveDuressToken(secret, context, identity, counter, encoding, maxTolerance) CANARY-DURESS: Derive an encoded duress token for a specific identity, with collision avoidance against normal tokens within ±(2 × maxTolerance) counter values. **maxTolerance is required** — it must match the tolerance used by verifiers. Both maxTolerance and tolerance are capped at MAX_TOLERANCE (10). Algorithm: `HMAC-SHA256(secret, utf8(context + ":duress") || 0x00 || utf8(identity) || counter_be32)`. Retries with suffix bytes 0x01–0xFF until the encoded token is distinct from any normal token in the tolerance window. ```typescript deriveDuressToken( secret: Uint8Array | string, context: string, identity: string, // e.g. customer ID, Nostr pubkey counter: number, encoding: TokenEncoding, // use DEFAULT_ENCODING for single word maxTolerance: number, // REQUIRED — must match verifier's tolerance ): string deriveDuressToken(secret, 'aviva', 'customer-123', 42, DEFAULT_ENCODING, 1) // e.g. 'anchor' (guaranteed ≠ any normal token in ±2 window) ``` ### deriveDuressTokenBytes(secret, context, identity, counter) CANARY-DURESS: Derive raw duress token bytes without encoding or collision avoidance. ```typescript deriveDuressTokenBytes( secret: Uint8Array | string, context: string, identity: string, counter: number, ): Uint8Array ``` ### verifyToken(secret, context, counter, input, identities, options?) CANARY-DURESS: Verify a spoken/entered token against a group. Checks in priority order: normal (exact counter) → duress (all identities, full tolerance window) → normal (remaining tolerance window) → invalid. ```typescript verifyToken( secret: Uint8Array | string, context: string, counter: number, input: string, identities: string[], // identity strings for duress detection options?: VerifyOptions, ): TokenVerifyResult verifyToken(secret, 'aviva', 42, 'marble', ['customer-123']) // { status: 'valid' } verifyToken(secret, 'aviva', 42, 'anchor', ['customer-123']) // { status: 'duress', identities: ['customer-123'] } verifyToken(secret, 'aviva', 42, 'unknown', ['customer-123']) // { status: 'invalid' } // With tolerance window verifyToken(secret, 'aviva', 42, 'marble', [], { tolerance: 1 }) // { status: 'valid' } — accepts tokens from counter 41, 42, or 43 ``` ### deriveLivenessToken(secret, context, identity, counter) CANARY-DURESS: Derive a liveness heartbeat token for a dead man's switch. If heartbeats stop arriving, the implementation triggers its DMS response. Algorithm: `HMAC-SHA256(secret, utf8(context + ":alive") || 0x00 || utf8(identity) || counter_be32)` ```typescript deriveLivenessToken( secret: Uint8Array | string, context: string, identity: string, counter: number, ): Uint8Array const heartbeat = deriveLivenessToken(secret, 'my-app', 'alice', currentCounter) // Uint8Array(32) [...] — publish periodically; absence triggers DMS ``` ### PRESETS Built-in threat-profile presets for group creation. See Preset Tables section. ```typescript const PRESETS: Readonly>> PRESETS['family'] // { wordCount: 1, rotationInterval: 604800, description: '...' } PRESETS['field-ops'] // { wordCount: 2, rotationInterval: 86400, description: '...' } PRESETS['enterprise'] // { wordCount: 2, rotationInterval: 172800, description: '...' } ``` ### deriveBeaconKey(seedHex) Derive a 256-bit AES key from the group seed for beacon and duress encryption. Deterministic: same seed always yields the same key. ```typescript deriveBeaconKey(seedHex: string): Uint8Array const key = deriveBeaconKey(state.seed) // Uint8Array(32) [...] ``` ### encryptBeacon(key, geohash, precision) Encrypt a location beacon payload with the group's beacon key. Returns a base64 string for a Nostr event's `content` field. (async, AES-256-GCM) ```typescript encryptBeacon( key: Uint8Array, geohash: string, precision: number, ): Promise const key = deriveBeaconKey(state.seed) const content = await encryptBeacon(key, 'gcpvjb', 6) // 'base64-encoded-ciphertext...' ``` ### decryptBeacon(key, content) Decrypt a location beacon event's content. Throws if the key is wrong or the ciphertext is tampered with. ```typescript decryptBeacon(key: Uint8Array, content: string): Promise const payload = await decryptBeacon(key, content) // { geohash: 'gcpvjb', precision: 6, timestamp: 1709461234 } ``` ### buildDuressAlert(memberPubkey, location) Construct a duress alert payload. Pass `null` for location when no location is available. ```typescript buildDuressAlert( memberPubkey: string, location: DuressLocation | null, ): DuressAlert buildDuressAlert('', { geohash: 'gcpvjbsm5', precision: 9, locationSource: 'verifier' }) // { type: 'duress', member: '', geohash: 'gcpvjbsm5', precision: 9, locationSource: 'verifier', timestamp: ... } buildDuressAlert('', null) // { type: 'duress', member: '', geohash: '', precision: 0, locationSource: 'none', timestamp: ... } ``` ### encryptDuressAlert(key, alert) Encrypt a duress alert with the group's beacon key. (async, AES-256-GCM) ```typescript encryptDuressAlert(key: Uint8Array, alert: DuressAlert): Promise const content = await encryptDuressAlert(key, alert) // 'base64-encoded-ciphertext...' ``` ### decryptDuressAlert(key, content) Decrypt a duress alert event's content. ```typescript decryptDuressAlert(key: Uint8Array, content: string): Promise const alert = await decryptDuressAlert(key, content) // { type: 'duress', member: '...', geohash: '...', ... } ``` ### getCounter(timestampSec, rotationIntervalSec?) Derive the current counter from a Unix timestamp and rotation interval. ```typescript getCounter( timestampSec: number, rotationIntervalSec: number = 604_800, // 7 days ): number getCounter(Math.floor(Date.now() / 1000)) // current weekly counter getCounter(Math.floor(Date.now() / 1000), 86_400) // current daily counter getCounter(1_000_000, 604_800) // 1 ``` ### counterToBytes(counter) Serialise a counter to an 8-byte big-endian Uint8Array (same encoding as TOTP, RFC 6238). ```typescript counterToBytes(counter: number): Uint8Array counterToBytes(42) // Uint8Array(8) [0, 0, 0, 0, 0, 0, 0, 42] ``` ### DEFAULT_ROTATION_INTERVAL Seven days in seconds. ```typescript const DEFAULT_ROTATION_INTERVAL: number // 604_800 ``` ### WORDLIST The complete 2048-word en-v1 English wordlist for spoken-word encoding. ```typescript const WORDLIST: readonly string[] // 2048 entries ``` ### WORDLIST_SIZE ```typescript const WORDLIST_SIZE: number // 2048 ``` ### getWord(index) Return the word at the given index. Throws `RangeError` if index is outside 0–2047. ```typescript getWord(index: number): string getWord(0) // 'ability' getWord(2047) // 'zoo' getWord(2048) // throws RangeError ``` ### indexOf(word) Return the index of a word, or -1 if not in the wordlist. ```typescript indexOf(word: string): number indexOf('marble') // e.g. 1103 indexOf('unknown') // -1 ``` --- ## canary-kit/token The universal CANARY protocol — context-string-based derivation, duress, and liveness. Use this directly when you need fine-grained control (custom context strings, arbitrary identity sets, raw bytes). All functions from the main barrel (`deriveToken`, `deriveTokenBytes`, `deriveDuressToken`, `deriveDuressTokenBytes`, `verifyToken`, `deriveLivenessToken`) are in this module, plus: ### deriveDirectionalPair(secret, namespace, roles, counter, encoding?) Derive two distinct tokens from the same secret — one per role. Neither token can be derived from the other without the shared secret, preventing the "echo problem" where the second speaker parrots the first. Each token uses `context = ${namespace}:${role}`. ```typescript deriveDirectionalPair( secret: Uint8Array | string, namespace: string, roles: [string, string], counter: number, encoding: TokenEncoding = DEFAULT_ENCODING, ): DirectionalPair deriveDirectionalPair(secret, 'aviva', ['caller', 'agent'], 42) // { caller: 'marble', agent: 'lantern' } // caller speaks 'marble', agent speaks 'lantern' // neither can be derived from the other ``` --- ## canary-kit/encoding Output encoding for CANARY tokens. All functions accept raw `Uint8Array` bytes and return human-readable strings. ### encodeAsWords(bytes, count?, wordlist?) Encode raw bytes as words using 11-bit indices into the wordlist. Each word uses 2 consecutive bytes: `readUint16BE(bytes, i*2) % 2048`. ```typescript encodeAsWords( bytes: Uint8Array, count: number = 1, wordlist: readonly string[] = WORDLIST, ): string[] encodeAsWords(someBytes, 1) // ['marble'] encodeAsWords(someBytes, 3) // ['marble', 'lantern', 'copper'] // Custom wordlist (must be exactly 2048 entries) encodeAsWords(someBytes, 1, myCustomWordlist) ``` Throws `RangeError` if `wordlist.length !== 2048`, `count` is outside 1–16, or `bytes.length < count * 2`. ### encodeAsPin(bytes, digits?) Encode raw bytes as a zero-padded numeric PIN. ```typescript encodeAsPin(bytes: Uint8Array, digits: number = 4): string encodeAsPin(someBytes, 4) // '0472' encodeAsPin(someBytes, 6) // '047291' encodeAsPin(someBytes, 10) // '0472918345' ``` Throws `RangeError` if `digits` is outside 1–10. ### encodeAsHex(bytes, length?) Encode raw bytes as a lowercase hex string. ```typescript encodeAsHex(bytes: Uint8Array, length: number = 8): string encodeAsHex(someBytes, 8) // 'a3f2c1d4' encodeAsHex(someBytes, 16) // 'a3f2c1d4e5b60789' encodeAsHex(someBytes, 64) // full 32-byte hex ``` Throws `RangeError` if `length` is outside 1–64. ### encodeToken(bytes, encoding?) Encode raw bytes using the specified encoding format. Words are space-joined. ```typescript encodeToken(bytes: Uint8Array, encoding: TokenEncoding = DEFAULT_ENCODING): string encodeToken(bytes) // 'marble' (default: single word) encodeToken(bytes, { format: 'words', count: 2 }) // 'marble lantern' encodeToken(bytes, { format: 'pin', digits: 6 }) // '047291' encodeToken(bytes, { format: 'hex', length: 16 }) // 'a3f2c1d4e5b60789' encodeToken(bytes, { format: 'words', count: 1, wordlist: myWordlist }) // 'custom-word' ``` --- ## canary-kit/session High-level two-party directional verification sessions. Wraps the low-level token API with role awareness, time-based counter management, and optional duress detection. ### generateSeed() Generate a cryptographically secure 256-bit session seed using `crypto.getRandomValues`. ```typescript generateSeed(): Uint8Array const seed = generateSeed() // Uint8Array(32) [random bytes...] ``` ### deriveSeed(masterKey, ...components) Derive a seed deterministically from a master key and string components. Null-byte separators prevent concatenation ambiguity. Algorithm: `HMAC-SHA256(masterKey, utf8(components[0]) || 0x00 || utf8(components[1]) || ...)` ```typescript deriveSeed( masterKey: Uint8Array | string, ...components: string[] ): Uint8Array deriveSeed(masterKey, 'session', 'customer-123') // Uint8Array(32) [deterministic bytes...] // Different components always produce different seeds deriveSeed(masterKey, 'session', 'customer-456') // Uint8Array(32) [different deterministic bytes...] ``` ### createSession(config) Create a directional verification session with role awareness and time management. ```typescript createSession(config: SessionConfig): Session // Phone call verification (using preset) const callerSession = createSession({ secret: sharedSecret, namespace: 'aviva', roles: ['caller', 'agent'], myRole: 'caller', preset: 'call', theirIdentity: 'customer-123', // for duress detection }) // Physical handoff (single-use) const handoffSession = createSession({ secret: taskSecret, namespace: 'dispatch', roles: ['driver', 'recipient'], myRole: 'driver', preset: 'handoff', counter: taskId, }) // Manual configuration const session = createSession({ secret: sharedSecret, namespace: 'my-app', roles: ['alice', 'bob'], myRole: 'alice', rotationSeconds: 30, tolerance: 1, encoding: { format: 'words', count: 2 }, }) ``` Throws if `roles[0] === roles[1]` or if `myRole` is not one of the two roles. #### session.counter(nowSec?) Get the current counter (time-based or fixed). ```typescript session.counter(): number session.counter(nowSec) // pass explicit time for testing ``` #### session.myToken(nowSec?) Get the token I speak to prove my identity. ```typescript session.myToken(): string // e.g. 'marble' ``` #### session.theirToken(nowSec?) Get the token I expect to hear from the other party. ```typescript session.theirToken(): string // e.g. 'lantern' ``` #### session.verify(spoken, nowSec?) Verify a word spoken to me against the other party's context. Applies the configured tolerance window and duress detection. ```typescript session.verify(spoken: string, nowSec?: number): TokenVerifyResult session.verify('lantern') // { status: 'valid' } session.verify('anchor') // if duress token for customer-123 // { status: 'duress', identities: ['customer-123'] } session.verify('unknown') // { status: 'invalid' } ``` #### session.pair(nowSec?) Get both tokens at once, keyed by role name. ```typescript session.pair(): DirectionalPair session.pair() // { caller: 'marble', agent: 'lantern' } ``` ### SESSION_PRESETS Built-in session presets. See Session Preset Table section. ```typescript const SESSION_PRESETS: Readonly>> SESSION_PRESETS.call // { wordCount: 1, rotationSeconds: 30, tolerance: 1, directional: true, description: '...' } SESSION_PRESETS.handoff // { wordCount: 1, rotationSeconds: 0, tolerance: 0, directional: true, description: '...' } ``` --- ## canary-kit/wordlist Direct access to the en-v1 wordlist and lookup utilities. All four exports (`WORDLIST`, `WORDLIST_SIZE`, `getWord`, `indexOf`) are documented in the main barrel section above. ### Wordlist properties - 2048 words - Based on BIP-39 English, filtered for verbal verification - No homophones, no phonetic near-collisions, no emotionally charged words - Backfilled with concrete, unambiguous supplementary words - All words are 3–8 characters, lowercase alphabetic only ```typescript WORDLIST[0] // 'ability' WORDLIST[1023] // 'merge' WORDLIST[2047] // 'zoo' ``` --- ## canary-kit/nostr Nostr event builders for SSG (Simple Shared Secret) groups. Uses standard Nostr kinds — no custom event kinds. All builders return unsigned events (`UnsignedEvent`) — sign with your own Nostr library (e.g. `nostr-tools`). Three event kinds are used: - **kind 30078** (parameterised replaceable) — group state and stored signals. The d-tag uses the `ssg/` namespace prefix. - **kind 20078** (ephemeral) — real-time signals between group members. - **kind 14** (rumour / unsigned inner event) — NIP-17 gift-wrapped DMs carrying seed distribution, reseed, and other private payloads. ### KINDS ```typescript const KINDS = { groupState: 30_078, // parameterised replaceable — group state and stored signals signal: 20_078, // ephemeral — real-time signals giftWrap: 1_059, // NIP-17 gift wrap (consumer wraps kind 14 rumours) } as const ``` ### buildGroupStateEvent(params) Build an unsigned kind 30078 group state event. The d-tag uses plaintext `ssg/` since content is NIP-44 encrypted. Includes NIP-32 labels for the SSG namespace and p-tags for each member. ```typescript buildGroupStateEvent(params: GroupStateEventParams): UnsignedEvent interface GroupStateEventParams { groupId: string members: string[] // hex pubkeys encryptedContent: string // NIP-44 encrypted group config rotationInterval?: number // seconds between word rotations tolerance?: number // counter tolerance window expiration?: number // NIP-40 expiration (unix seconds) } buildGroupStateEvent({ groupId: 'alpine-team-1', members: ['', ''], encryptedContent: '', rotationInterval: 86_400, tolerance: 1, expiration: Math.floor(Date.now() / 1000) + 30 * 86_400, }) // { kind: 30078, content: '...', tags: [['d','ssg/alpine-team-1'],['p',''],['p',''],['L','ssg'],['l','group','ssg'],['rotation','86400'],['tolerance','1'],['expiration','...']], created_at: ... } ``` ### buildStoredSignalEvent(params) Build an unsigned kind 30078 stored signal event. The d-tag uses a SHA-256 hash of the group ID (for privacy) scoped by signal type: `ssg/:`. Includes a 7-day NIP-40 expiration tag. ```typescript buildStoredSignalEvent(params: StoredSignalEventParams): UnsignedEvent interface StoredSignalEventParams { groupId: string signalType: string // e.g. 'counter-advance', 'word-used' encryptedContent: string } buildStoredSignalEvent({ groupId: 'alpine-team-1', signalType: 'counter-advance', encryptedContent: '', }) // { kind: 30078, content: '...', tags: [['d','ssg/:counter-advance'],['expiration','...']], created_at: ... } ``` ### buildSignalEvent(params) Build an unsigned kind 20078 ephemeral signal event. The d-tag uses a SHA-256 hash of the group ID (for privacy). A t-tag carries the signal type. No expiration tag — ephemeral events are not stored by relays. ```typescript buildSignalEvent(params: SignalEventParams): UnsignedEvent interface SignalEventParams { groupId: string signalType: string // e.g. 'beacon', 'word-used', 'counter-advance' encryptedContent: string } buildSignalEvent({ groupId: 'alpine-team-1', signalType: 'beacon', encryptedContent: '', }) // { kind: 20078, content: '...', tags: [['d','ssg/'],['t','beacon']], created_at: ... } ``` ### buildRumourEvent(params) Build an unsigned kind 14 rumour event for NIP-17 gift wrapping. The consumer must set the `pubkey` field before computing the event ID, then seal (NIP-44 encrypt + kind 13) and gift-wrap (kind 1059) the rumour. Used for seed distribution, reseed notifications, and member updates — anything that must be sent privately to a specific member. ```typescript buildRumourEvent(params: RumourEventParams): UnsignedEvent interface RumourEventParams { recipientPubkey: string // 64-char hex pubkey subject: string // e.g. 'ssg:seed-distribution', 'ssg:reseed', 'ssg:member-update' encryptedContent: string // NIP-44 encrypted payload groupEventId?: string // optional reference to the group state event } buildRumourEvent({ recipientPubkey: '', subject: 'ssg:seed-distribution', encryptedContent: '', groupEventId: '', }) // { kind: 14, content: '...', tags: [['p',''],['subject','ssg:seed-distribution'],['e','']], created_at: ... } ``` ### hashGroupId(groupId) SHA-256 hash a group ID for use in d-tags where privacy matters. ```typescript hashGroupId(groupId: string): string hashGroupId('alpine-team-1') // '3a7f...' (64-char hex) ``` --- ## canary-kit/beacon Encrypted location beacons and duress alerts for canary groups. Key derivation is synchronous (HMAC-SHA256). Encryption is asynchronous (AES-256-GCM via `crypto.subtle`). The IV is a random 12-byte prefix prepended to the ciphertext; the combined value is base64-encoded. All functions from the main barrel (`deriveBeaconKey`, `encryptBeacon`, `decryptBeacon`, `buildDuressAlert`, `encryptDuressAlert`, `decryptDuressAlert`) are documented in the main section above. --- ## canary-kit/sync Transport-agnostic group state synchronisation with authority model and replay protection. ### Types ```typescript /** A typed, serialisable description of a group state change. */ type SyncMessage = | { type: 'member-join'; pubkey: string; displayName?: string; timestamp: number; epoch: number; opId: string } | { type: 'member-leave'; pubkey: string; timestamp: number; epoch: number; opId: string } | { type: 'counter-advance'; counter: number; usageOffset: number; timestamp: number } | { type: 'reseed'; seed: Uint8Array; counter: number; timestamp: number; epoch: number; opId: string; admins: string[]; members: string[] } | { type: 'beacon'; lat: number; lon: number; accuracy: number; timestamp: number; opId: string } | { type: 'duress-alert'; lat: number; lon: number; timestamp: number; opId: string; subject?: string } | { type: 'liveness-checkin'; pubkey: string; timestamp: number; opId: string } | { type: 'state-snapshot'; seed: string; counter: number; usageOffset: number; members: string[]; admins: string[]; epoch: number; opId: string; timestamp: number; prevEpochSeed?: string } /** Result of applying a sync message, indicating whether it was accepted. */ interface SyncApplyResult { state: GroupState // The resulting group state (unchanged if rejected) applied: boolean // Whether the message was applied } /** Minimal interface any sync transport must implement. */ interface SyncTransport { send(groupId: string, message: SyncMessage, recipients?: string[]): Promise subscribe(groupId: string, onMessage: (msg: SyncMessage, sender: string) => void): () => void disconnect(): void } /** Abstracts event signing and NIP-44 encryption for transports that need it. */ interface EventSigner { pubkey: string sign(event: unknown): Promise encrypt(plaintext: string, recipientPubkey: string): Promise decrypt(ciphertext: string, senderPubkey: string): Promise } ``` ### Functions ```typescript decodeSyncMessage(json: string): SyncMessage // Parse and validate a JSON sync message. Throws on invalid input or wrong protocolVersion. encodeSyncMessage(msg: SyncMessage): string // Serialise a sync message to JSON. Injects protocolVersion automatically. applySyncMessage(state: GroupState, msg: SyncMessage, nowSec?: number, sender?: string): GroupState // Apply a sync message. Returns new state, or SAME REFERENCE if rejected (silent rejection). applySyncMessageWithResult(state: GroupState, msg: SyncMessage, nowSec?: number, sender?: string): SyncApplyResult // Same as applySyncMessage but returns { state, applied } for observability. ``` **IMPORTANT — silent rejection:** `applySyncMessage` returns the unchanged GroupState reference when a message is rejected. Common causes of silent rejection: - Privileged action without sender, or sender not in `group.admins` (I1) - Replayed opId already in `consumedOps` (I2) - Epoch mismatch: non-reseed ops must match local epoch (I3), reseed must be local epoch + 1 (I4) - Stale epoch for any privileged op (I6) - `counter-advance` without sender, or sender not in `group.members` - `counter-advance` that would regress the effective counter (monotonic) - Fire-and-forget messages older than 5 minutes or >60s in the future Use `applySyncMessageWithResult` when you need to log or alert on rejected messages. ### Envelope Encryption ```typescript deriveGroupKey(seed: string): Uint8Array // Derive AES-256-GCM group key from seed deriveGroupIdentity(seed: string): Uint8Array // Derive HMAC signing key from seed hashGroupTag(groupId: string): string // Privacy-preserving group tag hash encryptEnvelope(key: Uint8Array, plaintext: string): Promise // AES-256-GCM encrypt decryptEnvelope(key: Uint8Array, ciphertext: string): Promise // AES-256-GCM decrypt ``` ### Authority Invariants | ID | Rule | |---|---| | I1 | Sender must be admin for privileged actions (member-join, member-leave of others) | | I2 | opId replay guard within epoch (consumedOps set) | | I3 | Non-reseed privileged ops: epoch must match local epoch | | I4 | Reseed: epoch must equal local epoch + 1 | | I5 | Snapshot: epoch >= local epoch, anti-rollback for same-epoch | | I6 | Stale epoch rejection for all privileged ops | ### Constants ```typescript FIRE_AND_FORGET_FRESHNESS_SEC = 300 // Max age for fire-and-forget messages MAX_FUTURE_SKEW_SEC = 60 // Max future timestamp skew PROTOCOL_VERSION = 2 // Current sync protocol version ``` --- ## Preset Tables ### Group Presets Used with `createGroup({ preset: '...' })`. | Preset | Words | Rotation | Entropy | Use case | |---|---|---|---|---| | `family` | 1 | 7 days (604,800 s) | ~11 bits | Casual family/friend verification. Single word, weekly rotation. Adequate for live voice calls. | | `field-ops` | 2 | 24 hours (86,400 s) | ~22 bits | Journalism, activism, field operations. Two-word phrases with daily rotation. Use burn-after-use for maximum protection. | | `enterprise` | 2 | 48 hours (172,800 s) | ~22 bits | Corporate incident response. Two-word phrases with 48-hour rotation. Balances security with operational convenience. | Explicit `rotationInterval` or `wordCount` fields override the preset value. ### Session Presets Used with `createSession({ preset: '...' })`. | Preset | Words | Rotation | Tolerance | Directional | Use case | |---|---|---|---|---|---| | `call` | 1 | 30 seconds | 1 | Yes | Phone verification for insurance, banking, and call centres. Deepfake-proof — cloning a voice does not help derive the current word. | | `handoff` | 1 | single-use | 0 | Yes | Physical handoff for rideshare, delivery, and task completion. Counter is the task/event ID — no time dependency. | --- ## Nostr Event Kinds Uses standard Nostr kinds — no custom event kinds. | Kind | Type | KINDS key | Description | |---|---|---|---| | 30078 | Parameterised replaceable | `groupState` | Group state (d-tag `ssg/`) and stored signals (d-tag `ssg/:`) | | 20078 | Ephemeral | `signal` | Real-time signals between group members (beacon, word-used, counter-advance) | | 14 → 1059 | NIP-17 gift wrap | `giftWrap` | Seed distribution, reseed, member updates (kind 14 rumour sealed + wrapped) | Kind 30078 events use the `ssg/` d-tag namespace prefix. Kind 20078 events hash the group ID in the d-tag for privacy. Kind 14 rumours are sealed (kind 13) and gift-wrapped (kind 1059) by the consumer — the library builds the unsigned rumour only. --- ## Usage Patterns ### Phone Verification (Bank / Insurance) The bank's system and the customer both derive words from a shared per-customer secret. The customer speaks their word; the agent speaks theirs. Neither word can be derived from the other without the secret — voice cloning is defeated. ```typescript import { createSession, deriveSeed } from 'canary-kit/session' // Bank side: derive a per-customer secret from a master key const masterKey = process.env.CANARY_MASTER_KEY // 64-char hex, stored securely const customerSecret = deriveSeed(masterKey, 'aviva-verify', customerId) // Create session (bank agent's perspective) const agentSession = createSession({ secret: customerSecret, namespace: 'aviva', roles: ['caller', 'agent'], myRole: 'agent', preset: 'call', theirIdentity: customerId, // for duress detection }) // What the agent says to prove they are the real bank: const agentWord = agentSession.myToken() console.log(`Agent says: "${agentWord}"`) // What the agent expects to hear from the caller: const expectedCallerWord = agentSession.theirToken() // Verify what the caller actually said: const spokenByCustomer = 'marble' // received from phone const result = agentSession.verify(spokenByCustomer) if (result.status === 'valid') { console.log('Customer verified — proceed with call') } else if (result.status === 'duress') { console.log('DURESS SIGNAL — alert security, do not reveal to caller') console.log('Signallers:', result.identities) } else { console.log('Verification failed — possible impersonation attempt') } // Customer side (mobile app) const customerSession = createSession({ secret: customerSecret, namespace: 'aviva', roles: ['caller', 'agent'], myRole: 'caller', preset: 'call', }) const customerWord = customerSession.myToken() // 'marble' — customer speaks this const bankWord = customerSession.theirToken() // 'lantern' — customer expects this from bank ``` ### Family Group Verification All members derive the same word from a shared seed. On a video or voice call, everyone says the word. If someone is under duress, they say their personal duress word instead — silent distress signal. ```typescript import { createGroup, getCurrentWord, getCurrentDuressWord, verifyWord, syncCounter } from 'canary-kit' // Create group (group admin) let state = createGroup({ name: 'Family', members: ['', '', ''], preset: 'family', }) // Share state.seed with all members via secure channel (e.g. Nostr DM, NIP-44 encrypted) // On a call — all members derive the same word const syncedState = syncCounter(state) // refresh after loading from storage const todaysWord = getCurrentWord(syncedState) console.log(`Say to confirm identity: "${todaysWord}"`) // Alice's personal duress word (if she's coerced): const aliceDuressWord = getCurrentDuressWord(syncedState, '') // Verifying what someone said on the call: const spoken = 'marble' // heard on the call const result = verifyWord(spoken, syncedState.seed, syncedState.members, syncedState.counter) switch (result.status) { case 'verified': console.log('Identity confirmed') break case 'duress': console.log('DURESS — member under coercion:', result.members) break case 'stale': console.log('Token is one window behind — sync clocks') break case 'failed': console.log('Unknown word — possible impersonation') break } // Burn-after-use (field-ops scenario) state = advanceCounter(state) // next call will use a different word // Add a new member state = addMember(state, '') // Distribute new seed to diana via encrypted Nostr DM // Remove a compromised member and reseed atomically state = removeMemberAndReseed(state, '') // Distribute new state.seed to remaining members ``` ### Nostr Group Integration Publish group state and encrypted seeds to Nostr relays so all members can synchronise. Uses standard Nostr kinds (30078, 20078, NIP-17 gift wrap) — no custom event kinds. ```typescript import { createGroup, deriveBeaconKey, encryptBeacon } from 'canary-kit' import { KINDS, buildGroupStateEvent, buildSignalEvent, buildRumourEvent, } from 'canary-kit/nostr' // 1. Create the group let state = createGroup({ name: 'Alpine Team', members: ['', ''], preset: 'field-ops', }) // 2. Publish a kind 30078 group state event (encrypted content via NIP-44) const groupEvent = buildGroupStateEvent({ groupId: 'alpine-team-1', members: state.members, encryptedContent: '', rotationInterval: state.rotationInterval, }) // sign and publish groupEvent to relays // 3. Send seed to each member via NIP-17 gift wrap (kind 14 rumour → seal → wrap) for (const memberPubkey of state.members) { const rumour = buildRumourEvent({ recipientPubkey: memberPubkey, subject: 'ssg:seed-distribution', encryptedContent: '', groupEventId: '', }) // set pubkey, compute id, then seal (kind 13) and gift-wrap (kind 1059) } // 4. Publish periodic location beacons as kind 20078 ephemeral signals const beaconKey = deriveBeaconKey(state.seed) const encryptedBeacon = await encryptBeacon(beaconKey, 'gcpvjb', 6) const beaconEvent = buildSignalEvent({ groupId: 'alpine-team-1', signalType: 'beacon', encryptedContent: encryptedBeacon, }) // sign and publish beaconEvent // 5. On receiving a beacon event from a member const payload = await decryptBeacon(beaconKey, receivedBeaconEvent.content) console.log(`Member at geohash ${payload.geohash} (precision ${payload.precision})`) console.log(`Last seen: ${new Date(payload.timestamp * 1000).toISOString()}`) ``` ### Universal Token API (Non-Group Use) Use the low-level token API directly for any HMAC-based verification scheme — TROTT ride verification, API authentication, one-time codes. ```typescript import { deriveToken, deriveDuressToken, verifyToken } from 'canary-kit/token' import { encodeToken } from 'canary-kit/encoding' // Generate a shared secret (store securely, e.g. in a database) const secret = crypto.getRandomValues(new Uint8Array(32)) // TROTT ride handshake: driver proves to rider they are the right driver const rideId = 42_001 // use ride ID as counter for single-use const driverToken = deriveToken(secret, 'trott:driver', rideId, { format: 'words', count: 1 }) const riderToken = deriveToken(secret, 'trott:rider', rideId, { format: 'words', count: 1 }) // Rider's app shows: "Ask driver to say: 'marble'" // Driver's app shows: "Say 'lantern' to rider after they say 'marble' to you" // Time-based TOTP-style with PIN encoding const counter = Math.floor(Date.now() / 30_000) // 30-second window const pin = deriveToken(secret, 'my-app:auth', counter, { format: 'pin', digits: 6 }) // e.g. '047291' // Verify with tolerance (accept ±1 window for clock skew) const result = verifyToken(secret, 'my-app:auth', counter, enteredPin, [], { encoding: { format: 'pin', digits: 6 }, tolerance: 1, }) // { status: 'valid' } or { status: 'invalid' } ``` ### Dead Man's Switch (Liveness Heartbeat) ```typescript import { deriveLivenessToken } from 'canary-kit/token' import { getCounter } from 'canary-kit' const HEARTBEAT_INTERVAL = 3600 // 1 hour // Publisher: send a liveness heartbeat every hour setInterval(async () => { const counter = getCounter(Math.floor(Date.now() / 1000), HEARTBEAT_INTERVAL) const heartbeat = deriveLivenessToken(secret, 'dms:alice', 'alice', counter) await publish(heartbeat) // send to monitoring server }, HEARTBEAT_INTERVAL * 1000) // Monitor: if heartbeats stop arriving, trigger DMS response // The monitor verifies each heartbeat matches the expected derivation // for the current counter (within ±1 tolerance for timing drift) ```