SPEC.md → v0.4 - §3.1 BIP-39 recovery phrases (12-word hashed with "kez-bip39-12-v1", 24-word = seed), parsing rules, test vectors. - §7 nostr: kind 30078 canonical (MUST); verifiers SHOULD also check kind-1 notes and the kind-0 `about`. - §9.1 sigchain HTTP store API; §11 BIP-39 primitive; §15 Python and kez-chat; §16 applications (kez-chat tags); changelog. nodejs kez-channels - NostrChannel now requests kinds 0, 1, 30078 and extracts the proof body per kind (kind-0 `about` JSON-decoded), mirroring Rust's extract_proof_body. Proofs posted as kind-1 notes (e.g. via NIP-07 from kez-chat) previously failed to verify in Node. - Tests for kind-1 Markdown notes, kind-0 about, and extractProofBody. Docs - kez-chat/web/NOSTR-CHAT.md rewritten for the current protocol: `q` routing tag, kinds 4242/4244/10142/0/10002, KEZ-signed heartbeats, v1–v4 envelopes, receipts, relay ordering, honest metadata model. - Stale `#h` comments in nostr-id.ts / nostr-transport.ts. - rust/README.md nostr channel rows list all three kinds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
69 lines
3.1 KiB
TypeScript
69 lines
3.1 KiB
TypeScript
// 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));
|
||
}
|