Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 231 additions & 0 deletions lib/buzz-identity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
"use strict";

// The did:key <-> npub co-signed identity binding (DIVE-3138 item 2).
//
// Buzz identity is secp256k1 x-only BIP-340; OpenAgent identity is did:key over
// ed25519. THERE IS NO DERIVATION BETWEEN THEM (NIP-OA.md:22 declines to define
// one, and none exists in either direction). So the binding cannot be computed —
// it has to be ASSERTED BY BOTH KEYS and checked by a verifier. That is the whole
// design: an agent holds two keypairs, and this receipt is the only artefact
// tying them together.
//
// This is a NEW receipt type alongside lib/receipts.js rather than a call into
// its cosign(). receipts.cosign() assumes both halves are ed25519 and signs the
// same bytes with both; here the two halves take DIFFERENT INPUTS at different
// layers (see PREIMAGE VS DIGEST below), so reusing it would be wrong in a way
// that still produces two valid-looking signatures.

const crypto = require("crypto");
const {
canonicalBytes,
toPublicKey,
toPrivateKey,
publicPemFromPrivate,
didKeyFromPublicKey,
} = require("./provenance");
const { npubEncode, npubDecode, schnorrPublicKey, schnorrSign, schnorrVerify } = require("./nostr");

// A DISTINCT domain separator. NIP-OA's is exactly `nostr:agent-auth:`
// (NIP-OA.md:44); ours must differ so neither signature can ever be replayed as
// the other. A schnorr signature over this binding must not also validate as an
// owner attestation, and vice versa.
const DOMAIN = "5dive:agent-identity-binding:v1:";

// Fields a statement may carry. Anything else makes it malformed — reject-on-
// ambiguity, borrowed from NIP-OA's own discipline (:28 two auth tags = no valid
// tag; :41 wrong element count = malformed).
const STATEMENT_FIELDS = new Set(["v", "did", "npub", "agent", "relay", "at", "nonce"]);

/**
* Canonicalize a relay URL per NIP-AE.md:24 — lowercase scheme and host, strip a
* default port (443 for wss/https, 80 for ws/http), strip a trailing slash on an
* otherwise-empty path, path otherwise verbatim.
*
* The relay URL IS the community boundary, so a binding valid everywhere is a
* binding that leaks across communities. Reused here rather than inventing a
* second URL comparison.
*/
function canonicalizeRelay(url) {
const u = new URL(String(url));
const scheme = u.protocol.toLowerCase().replace(/:$/, "");
const host = u.hostname.toLowerCase();
const isSecure = scheme === "wss" || scheme === "https";
const defaultPort = isSecure ? "443" : "80";
const port = u.port && u.port !== defaultPort ? ":" + u.port : "";
const path = u.pathname === "/" ? "" : u.pathname;
return `${scheme}://${host}${port}${path}${u.search}`;
}

/**
* The unsigned statement both keys agree on.
* `at` is a claim, NOT an expiry — same reasoning as NIP-OA's created_at clauses
* (NIP-OA.md:100-103): it is self-declared by the signers and verification MUST
* NOT depend on the verifier's clock. Freshness, if needed, is enforced out of
* band against our own receipts store.
*/
function buildBinding({ did, npub, agent, relay, at, nonce }) {
if (!did || !npub || !agent || !relay || !at) {
throw new Error("buildBinding: did, npub, agent, relay, at all required");
}
if (!String(did).startsWith("did:key:z")) throw new Error("buildBinding: did must be a did:key");
const hexNpub = String(npub).startsWith("npub1") ? npubDecode(npub) : String(npub).toLowerCase();
if (!hexNpub || !/^[0-9a-f]{64}$/.test(hexNpub)) {
throw new Error("buildBinding: npub must be an npub1… or 64-char x-only hex");
}
return {
v: 1,
did: String(did),
npub: npubEncode(hexNpub), // stored in bech32 so the signed bytes pin the encoding too
agent: String(agent),
relay: canonicalizeRelay(relay),
at: Number(at),
nonce: nonce ? String(nonce) : crypto.randomBytes(16).toString("hex"),
};
}

// PREIMAGE VS DIGEST — this is not cosmetic, and it is where re-derivations die.
// Ed25519 hashes its message internally, so it signs the PREIMAGE. BIP-340 signs
// a 32-byte message, so it signs SHA256(preimage). Handing the same bytes to both
// is the mistake; both layers are pinned here and in test/buzz.js vectors.
function bindingPreimage(statement) {
return Buffer.concat([Buffer.from(DOMAIN, "utf8"), canonicalBytes(statement)]);
}
function bindingDigest(statement) {
return crypto.createHash("sha256").update(bindingPreimage(statement)).digest();
}

/** The did:key half: detached ed25519 over the PREIMAGE. */
function signDid(statement, privateKey) {
const key = publicPemFromPrivate(privateKey);
return {
alg: "ed25519",
by: didKeyFromPublicKey(key),
key,
sig: crypto.sign(null, bindingPreimage(statement), toPrivateKey(privateKey)).toString("base64"),
};
}

/** The npub half: BIP-340 schnorr over the 32-byte DIGEST. */
function signNpub(statement, secretHex, aux32) {
const pub = schnorrPublicKey(secretHex);
return {
alg: "bip340",
by: npubEncode(pub),
key: pub,
sig: schnorrSign(bindingDigest(statement), secretHex, aux32 || crypto.randomBytes(32)),
};
}

/** A fully co-signed binding = the statement + both halves. */
function cosignBinding(statement, edPrivateKey, nostrSecretHex, aux32) {
return {
statement,
sigs: [signDid(statement, edPrivateKey), signNpub(statement, nostrSecretHex, aux32)],
};
}

/**
* Verify a co-signed binding. Never throws.
*
* With requireBoth (the default, and the only sane setting), BOTH halves must be
* present and must verify against the keys the statement itself names. A
* one-sided binding is worthless BY CONSTRUCTION — a single signature proves only
* that one key made a claim about a key it does not control, which is exactly
* the claim an attacker wants to make.
*
* @param {object} cosigned {statement, sigs}
* @param {object} opts.requireBoth default true
* @param {string} opts.relay if given, the statement's relay must canonicalize to it
* @param {Set} opts.seenNonces if given, `nonce` must be unseen for this (did,npub) pair
*/
function verifyBinding(cosigned, { requireBoth = true, relay = null, seenNonces = null } = {}) {
const { statement, sigs } = cosigned || {};
if (!statement || typeof statement !== "object" || !Array.isArray(sigs) || sigs.length === 0) {
return { ok: false, reason: "malformed" };
}
if (statement.v !== 1) return { ok: false, reason: "unsupported statement version" };

// Reject ANY unknown field. A receipt asserting derivation between the keys
// (`derived_from`, `derivation`, …) is malformed, not merely wrong — no such
// derivation exists, so a field claiming one is a lie the format must not carry.
for (const k of Object.keys(statement)) {
if (!STATEMENT_FIELDS.has(k)) return { ok: false, reason: `unknown statement field: ${k}` };
}
for (const k of STATEMENT_FIELDS) {
if (statement[k] === undefined || statement[k] === null || statement[k] === "") {
return { ok: false, reason: `missing statement field: ${k}` };
}
}

const npubHex = npubDecode(statement.npub);
if (!npubHex) return { ok: false, reason: "statement npub is not a valid NIP-19 npub" };

if (relay) {
let want;
try {
want = canonicalizeRelay(relay);
} catch {
return { ok: false, reason: "verifier relay unparseable" };
}
if (statement.relay !== want) return { ok: false, reason: "relay mismatch (wrong community)" };
}

const preimage = bindingPreimage(statement);
const digest = crypto.createHash("sha256").update(preimage).digest();
const seen = { ed25519: false, bip340: false };

for (const s of sigs) {
if (!s || !s.alg || !s.by || !s.key || !s.sig) return { ok: false, reason: "incomplete signature" };
if (s.alg === "ed25519") {
if (s.by !== statement.did) return { ok: false, reason: "ed25519 signer is not the statement did" };
let derived;
try {
derived = didKeyFromPublicKey(s.key);
} catch {
return { ok: false, reason: "unparseable did key" };
}
if (derived !== s.by) return { ok: false, reason: "signer did/key mismatch" };
let ok = false;
try {
ok = crypto.verify(null, preimage, toPublicKey(s.key), Buffer.from(String(s.sig), "base64"));
} catch {
ok = false;
}
if (!ok) return { ok: false, reason: "bad ed25519 signature" };
seen.ed25519 = true;
} else if (s.alg === "bip340") {
if (String(s.key).toLowerCase() !== npubHex) {
return { ok: false, reason: "bip340 signer is not the statement npub" };
}
if (s.by !== statement.npub) return { ok: false, reason: "bip340 by/npub mismatch" };
if (!schnorrVerify(digest, npubHex, s.sig)) return { ok: false, reason: "bad bip340 signature" };
seen.bip340 = true;
} else {
return { ok: false, reason: `unknown signature alg: ${s.alg}` };
}
}

if (requireBoth && !(seen.ed25519 && seen.bip340)) {
return { ok: false, reason: "one-sided binding (both did and npub must sign)" };
}

if (seenNonces) {
const key = `${statement.did}|${statement.npub}|${statement.nonce}`;
if (seenNonces.has(key)) return { ok: false, reason: "replayed nonce" };
seenNonces.add(key);
}

return { ok: true, did: statement.did, npub: statement.npub, npubHex, agent: statement.agent };
}

module.exports = {
DOMAIN,
canonicalizeRelay,
buildBinding,
bindingPreimage,
bindingDigest,
signDid,
signNpub,
cosignBinding,
verifyBinding,
};
164 changes: 164 additions & 0 deletions lib/buzz-ingress.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"use strict";

// The inbound rail's untrusted-input boundary (DIVE-3138 item 1).
//
// THE RULE, stated so it can be tested:
//
// Every Buzz event is untrusted input. No Buzz event may mint a privilege,
// switch an auth profile, clear a gate, or authorise a spend — INCLUDING when
// it is validly signed, INCLUDING when it is signed by another agent we
// recognise, and INCLUDING when it carries a valid NIP-OA `auth` tag naming an
// owner key we trust.
//
// The third clause is the one Buzz's own NIPs make necessary rather than merely
// prudent: NIP-OA.md:72 ("Verifiers MUST NOT reinterpret a valid auth tag as an
// identity override"), :86 ("the agent key in event.pubkey is the only author
// key"), :16 (the same tag is REUSABLE across events — one leak is unlimited
// future events), :100-102 (its expiry constrains a self-declared created_at the
// agent itself controls, so an expired attestation is indistinguishable from a
// live one) and :99 (there is no revocation of already-issued credentials).
//
// So an `auth` tag's ceiling is DISPLAY. This module carries it in an
// `advisory` field, never in anything a caller can act on. Signature validity is
// likewise ADVISORY: Telegram messages arrive unsigned so nobody is tempted;
// Buzz events arrive cryptographically valid, which is exactly the property that
// makes a naive bridge fail open. Authenticated still means untrusted.
//
// WHERE THIS SITS. This is a library, deliberately: it is enforced at an ingress
// adapter BEFORE any 5dive verb is composed — not in a prompt and not in the
// agent's judgement. The Telegram plugin is the precedent for the POLICY and is
// explicitly NOT a code precedent (measured on plugins/telegram/server.ts:998
// and :1002 — its posture is an inert `<channel>` frame plus an MCP
// server-instruction string; no such adapter exists there to copy). This is
// deliberately stronger.

// The complete set of fields that cross the boundary. A normalized event is a
// closed record: an allowlist, not a redaction pass, so a field the relay adds
// tomorrow arrives dropped rather than arrives trusted.
const CARRIED = ["id", "pubkey", "kind", "created_at", "channel", "content"];

const str = (v) => (v === undefined || v === null ? "" : String(v));

/**
* Turn a raw relay event into an inert record. Returns null when the event is
* unusable — malformed means DROPPED, never ignored-and-continue (NIP-OA.md:28,
* :41, :66, :141-150 all take this line and we take it too).
*/
function normalizeEvent(raw) {
if (!raw || typeof raw !== "object") return null;
const id = str(raw.id).toLowerCase();
const pubkey = str(raw.pubkey).toLowerCase();
if (!/^[0-9a-f]{64}$/.test(id) || !/^[0-9a-f]{64}$/.test(pubkey)) return null;
const created_at = Number(raw.created_at);
if (!Number.isFinite(created_at)) return null;

const tags = Array.isArray(raw.tags) ? raw.tags : [];
const hTag = tags.find((t) => Array.isArray(t) && t[0] === "h");
const authTags = tags.filter((t) => Array.isArray(t) && t[0] === "auth");

const out = {
id,
pubkey,
kind: Number(raw.kind),
created_at,
channel: str(raw.channel || (hTag && hTag[1])),
// Content is carried as an OPAQUE STRING and nothing else. It is never
// parsed for commands, never an argument to a 5dive verb, never selects an
// auth profile, and a task ident inside it is a string until the agent
// re-reads that row from our own board.
content: str(raw.content),
advisory: {
// DISPLAY ONLY. NIP-OA.md:89 — provenance is shown "clearly distinguished
// from authorship". Two auth tags means NO valid tag (NIP-OA.md:28); a
// self-attestation (owner == event.pubkey) is invalid (:66).
ownerAttestationPresent: authTags.length === 1 && authTags[0].length === 4,
ownerAttestationOwner:
authTags.length === 1 && authTags[0].length === 4 && str(authTags[0][1]).toLowerCase() !== pubkey
? str(authTags[0][1]).toLowerCase()
: null,
},
// A permanently false capability marker. Anything downstream that needs a
// privilege can test this and will always be refused; it exists so the
// refusal is a property of the record rather than a habit of the reader.
grantsPrivilege: false,
};
return Object.freeze(out);
}

/**
* Does this event mention us? p-tag first (the relay populates mention_pubkeys
* for `--mention` and for real NIP-27 `nostr:npub1…`), then a content scan.
*
* MEASURED (DIVE-2895 delta): `nostr:<64-hex>` is NOT the NIP-27 form — NIP-27
* wants bech32. The Phase-0 conclusion that "the relay never populates
* mention_pubkeys" was a fact about what the spike SENT wearing the clothes of a
* fact about the relay. The hex fallback below therefore only ever buys us
* raw-hex senders, and is kept for exactly that.
*/
function detectMention(event, { ourHex, ourNpub, mentionPubkeys = [] } = {}) {
const hex = str(ourHex).toLowerCase();
if (!event) return null;
if (mentionPubkeys.some((p) => str(p).toLowerCase() === hex)) return "p-tag";
const content = event.content || "";
if (ourNpub && content.includes(`nostr:${ourNpub}`)) return "nip27";
if (hex && content.includes(hex)) return "hex-fallback";
return null;
}

/**
* Cold-start watermark.
*
* THE TRAP: a poll with no watermark replays the last N events as new. On a
* channel with history that is stale instructions arriving as if fresh — exactly
* what this boundary exists to stop being acted on.
*
* THE SECOND HALF OF THE TRAP, and the one that is easy to ship dead: the
* watermark must be CLAIMED EVEN WHEN THE CHANNEL IS EMPTY. A guard of the shape
* `if (events.length) seed()` never leaves cold start on a quiet channel, so the
* first real message that ever arrives is swallowed by the seeding branch
* instead of delivered. `seeded` is set unconditionally on the first tick.
*
* @param {object} state {seeded:boolean, high:number}
* @param {Array} events normalized events from this tick
* @returns {{state:object, deliver:Array, seeded:boolean}}
*/
function advanceWatermark(state, events) {
const prev = state && typeof state === "object" ? state : {};
const list = (Array.isArray(events) ? events : []).filter(Boolean);
const maxSeen = list.reduce((m, e) => Math.max(m, Number(e.created_at) || 0), 0);

if (!prev.seeded) {
// First tick: deliver NOTHING, and claim the watermark unconditionally —
// including on an empty channel, where `high` stays whatever it was (0) but
// `seeded` still flips. That flip is the whole fix.
return { state: { seeded: true, high: Math.max(Number(prev.high) || 0, maxSeen) }, deliver: [], seeded: true };
}

const high = Number(prev.high) || 0;
const deliver = list
.filter((e) => Number(e.created_at) > high)
.sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id));
return { state: { seeded: true, high: Math.max(high, maxSeen) }, deliver, seeded: false };
}

/**
* The full inbound rail for one tick: normalize -> drop malformed -> watermark
* -> mention filter. What comes back is inert data destined for the body of a
* message on the `5dive agent send` rail, and nothing else.
*/
function ingest(rawEvents, state, identity = {}) {
const normalized = (Array.isArray(rawEvents) ? rawEvents : []).map((r) => ({
norm: normalizeEvent(r),
mentionPubkeys: (r && r.mention_pubkeys) || [],
}));
const dropped = normalized.filter((n) => !n.norm).length;
const ok = normalized.filter((n) => n.norm);
const wm = advanceWatermark(state, ok.map((n) => n.norm));
const byId = new Map(ok.map((n) => [n.norm.id, n.mentionPubkeys]));
const delivered = wm.deliver
.map((e) => ({ event: e, via: detectMention(e, { ...identity, mentionPubkeys: byId.get(e.id) || [] }) }))
.filter((d) => d.via);
return { state: wm.state, seeded: wm.seeded, dropped, delivered };
}

module.exports = { CARRIED, normalizeEvent, detectMention, advanceWatermark, ingest };
Loading
Loading