Kez/kez-chat/web/src/lib/nostr-id.ts
Jason Tudisco b237ac2d9e docs+fix: SPEC v0.4, Node nostr channel checks kinds 0/1/30078, NOSTR-CHAT.md current
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>
2026-09-27 10:00:51 -06:00

69 lines
3.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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));
}