// Bridges the KEZ ed25519 identity onto Nostr's secp256k1 world. // // Nostr signs with secp256k1 (Schnorr); KEZ identities are ed25519. // The two curves can't be cross-derived, so we can't turn someone's // ed25519 primary into "their" Nostr pubkey. Instead: // // • The *signing* key is a secp256k1 key derived deterministically // from the user's own ed25519 seed (HKDF, domain-separated). It is // a pure transport credential — internal to this app, never the // user's real Nostr account, and its pubkey is never advertised. // // • *Addressing* is done by a tag derived from the recipient's // ed25519 PRIMARY (which is public, so any sender can compute it). // The recipient subscribes to relays filtering on the same tag. // // This keeps the whole thing "internal": nothing Nostr-specific leaks // into the UI or the KEZ identity model. Nostr is just the pipe. import { hkdf } from "@noble/hashes/hkdf"; import { sha256 } from "@noble/hashes/sha2"; import { bytesToHex } from "@noble/hashes/utils"; import type { Identity } from "./kez.js"; /** Regular event kind (1000–9999 → relays persist it, which the inbox needs). */ export const KEZ_DM_KIND = 4242; /** Tag name carrying the recipient address. `#q` filter on the relay * side. Used to be `h`, but NIP-29 (Simple Groups) treats `h` as a * group id — a NIP-29-aware relay would try to interpret our routing * tag as a group join and silently drop our events. `q` is a * less-claimed single letter (still indexable per NIP-01). * * See kez-chat/TODO.md "Day 1 #5" for the migration plan. */ export const ADDR_TAG = "q"; const SIGNKEY_SALT = new TextEncoder().encode("kez-chat:nostr-signkey"); const SIGNKEY_INFO = new TextEncoder().encode("v1"); const ADDR_SALT = new TextEncoder().encode("kez-chat:nostr-addr"); const ADDR_INFO = new TextEncoder().encode("v1"); /** * Derive the secp256k1 secret key this identity signs Nostr events with. * Deterministic from the 32-byte ed25519 seed, so the same account always * produces the same Nostr signer — but it reveals nothing about the seed. * * HKDF output is a uniformly random 32-byte value; the probability it is * not a valid secp256k1 scalar (≥ curve order n) is ~2⁻¹²⁸, i.e. never. */ export function nostrSecretFromSeed(seed: Uint8Array): Uint8Array { if (seed.length !== 32) throw new Error(`seed must be 32 bytes, got ${seed.length}`); return hkdf(sha256, seed, SIGNKEY_SALT, SIGNKEY_INFO, 32); } /** * Deterministic 32-byte (hex) address for a recipient, derived from their * public ed25519 primary. Both parties can compute it: the sender from the * directory lookup, the recipient from their own primary. Used as the value * of the `q` tag (ADDR_TAG) so a relay subscription can fan messages to the * right box. * * It is *not* the recipient's Nostr pubkey (can't be — wrong curve); it is an * opaque routing label. Using a hash also means the raw primary isn't sitting * in a relay-queryable tag. */ export function addrFromPrimary(primary: Identity): string { const bytes = new TextEncoder().encode(primary); return bytesToHex(hkdf(sha256, bytes, ADDR_SALT, ADDR_INFO, 32)); }