aboutsummaryrefslogtreecommitdiffstats
path: root/src/concord.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-02 10:09:42 -0700
committerAdam Soltys <[email protected]>2026-08-02 10:09:42 -0700
commit3205acf157b7f1cfb857a383f374ef4d739d82cc (patch)
treec61dd9e51b230106bc3134a22eef774dc1decd8c /src/concord.js
parent6ebae6bce794c8f2db8337cb189b9095975e36ad (diff)
downloadcoinosv3-3205acf157b7f1cfb857a383f374ef4d739d82cc.tar.gz
coinosv3-3205acf157b7f1cfb857a383f374ef4d739d82cc.zip
Chat: Concord encrypted community messaging, with a default coinos room
Implements the Concord protocol (CORD-01..04) — private streams, the control-plane fold, guestbook membership, owner-rooted authority — and ships a built-in "coinos" community founded by Adam's npub (owner/admin). Messages sign with the nostr login identity when a signer is live, else the wallet's NIP-06 key; all encryption rides derived stream keys so remote signers only ever sign seals. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
Diffstat (limited to 'src/concord.js')
-rw-r--r--src/concord.js234
1 files changed, 234 insertions, 0 deletions
diff --git a/src/concord.js b/src/concord.js
new file mode 100644
index 0000000..909fbd1
--- /dev/null
+++ b/src/concord.js
@@ -0,0 +1,234 @@
+// Concord (CORD-01..06) — private communities on nostr
+// https://github.com/concord-protocol/concord
+//
+// Core derivations and envelope crypto. Everything here is protocol-pure:
+// no DOM, no app state. The messages feature builds on top of this.
+
+import { sha256 } from "@noble/hashes/sha256";
+import { hkdf } from "@noble/hashes/hkdf";
+import { schnorr, secp256k1 } from "@noble/curves/secp256k1";
+import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
+import { finalizeEvent, generateSecretKey, getPublicKey, getEventHash, verifyEvent } from "nostr-tools/pure";
+import { nip44 } from "nostr-tools";
+
+const utf8 = (s) => new TextEncoder().encode(s);
+
+export const PERM = {
+ MANAGE_ROLES: 1n << 0n,
+ MANAGE_CHANNELS: 1n << 1n,
+ MANAGE_METADATA: 1n << 2n,
+ KICK: 1n << 3n,
+ BAN: 1n << 4n,
+ MANAGE_MESSAGES: 1n << 5n,
+ CREATE_INVITE: 1n << 6n,
+ VIEW_AUDIT_LOG: 1n << 8n,
+ MENTION_EVERYONE: 1n << 9n,
+};
+
+// ---- Appendix A derivations (frozen) ----
+
+const be64 = (n) => {
+ let b = new Uint8Array(8);
+ new DataView(b.buffer).setBigUint64(0, BigInt(n));
+ return b;
+};
+
+const concat = (...arrs) => {
+ let out = new Uint8Array(arrs.reduce((n, a) => n + a.length, 0));
+ let o = 0;
+ for (let a of arrs) out.set(a, o), (o += a.length);
+ return out;
+};
+
+// A.1: info = utf8(label) || 0x00 || id[32] || epoch_be[8]? (|| counter byte on scalar retry)
+const hkdf32 = (secret, label, id, epoch, counter) => {
+ let info = concat(utf8(label), new Uint8Array([0]), id);
+ if (epoch !== undefined) info = concat(info, be64(epoch));
+ if (counter !== undefined) info = concat(info, new Uint8Array([counter]));
+ return hkdf(sha256, secret, undefined, info, 32);
+};
+
+const isValidScalar = (b) => {
+ let n = BigInt("0x" + bytesToHex(b));
+ return n > 0n && n < secp256k1.CURVE.n;
+};
+
+// A.2 + A.3: derive a keypair whose x-only pubkey is the stream address
+export const groupKey = (label, secret, id, epoch) => {
+ let sk = hkdf32(secret, label, id, epoch);
+ for (let c = 0; !isValidScalar(sk); c++) sk = hkdf32(secret, label, id, epoch, c);
+ let pk = bytesToHex(schnorr.getPublicKey(sk));
+ return { sk, pk, convKey: nip44.getConversationKey(sk, pk) };
+};
+
+// A.4
+export const communityId = (ownerHex, saltHex) =>
+ bytesToHex(sha256(concat(utf8("concord/community"), hexToBytes(ownerHex), hexToBytes(saltHex))));
+
+const ZERO32 = new Uint8Array(32);
+
+export const controlKey = (root, cid, epoch = 0) =>
+ groupKey("concord/control", root, hexToBytes(cid), epoch);
+export const guestbookKey = (root, cid, epoch = 0) =>
+ groupKey("concord/guestbook", root, hexToBytes(cid), epoch);
+export const channelKey = (secret, channelId, epoch = 0) =>
+ groupKey("concord/channel", secret, hexToBytes(channelId), epoch);
+export const grantEid = (cid, memberHex) =>
+ bytesToHex(hkdf32(hexToBytes(cid), "concord/grant", hexToBytes(memberHex)));
+export const banlistEid = (cid) => bytesToHex(hkdf32(hexToBytes(cid), "concord/banlist", ZERO32));
+
+// ---- CORD-01 envelope ----
+// wrap(1059, signed by stream key) > seal(20013 encrypted | 20014 plaintext, author-signed) > rumor
+
+const rumorWithId = (rumor) => ({ ...rumor, id: getEventHash({ ...rumor, tags: rumor.tags || [] }) });
+
+// Sign a seal around a rumor. `signer` is a raw secret key (Uint8Array) or a
+// { signEvent } adapter (nostr-login extension/bunker). Plaintext seals
+// (control plane) carry the rumor JSON verbatim; encrypted seals
+// nip44-encrypt it under the stream conversation key.
+export const makeSeal = async (rumor, signer, streamConvKey, { plaintext = false } = {}) => {
+ let r = rumorWithId(rumor);
+ let content = plaintext ? JSON.stringify(r) : nip44.encrypt(JSON.stringify(r), streamConvKey);
+ let seal = { kind: plaintext ? 20014 : 20013, content, tags: [], created_at: rumor.created_at };
+ return signer instanceof Uint8Array ? finalizeEvent(seal, signer) : signer.signEvent(seal);
+};
+
+export const makeWrap = (seal, stream, { ephemeral = false } = {}) => {
+ let wrap = {
+ kind: ephemeral ? 21059 : 1059,
+ content: nip44.encrypt(JSON.stringify(seal), stream.convKey),
+ tags: [["p", getPublicKey(generateSecretKey())]],
+ created_at: seal.created_at,
+ };
+ return finalizeEvent(wrap, stream.sk);
+};
+
+export const wrapRumor = async (rumor, signer, stream, opts = {}) =>
+ makeWrap(await makeSeal(rumor, signer, stream.convKey, opts), stream, opts);
+
+// Open a 1059/21059 wrap fetched at a stream address. Returns { rumor, author, seal }
+// or null if any layer fails to parse, decrypt, or verify.
+export const openWrap = (wrap, stream) => {
+ try {
+ let seal = JSON.parse(nip44.decrypt(wrap.content, stream.convKey));
+ if (seal.kind !== 20013 && seal.kind !== 20014) return null;
+ if (!verifyEvent(seal)) return null;
+ let raw = seal.kind === 20014 ? seal.content : nip44.decrypt(seal.content, stream.convKey);
+ let rumor = JSON.parse(raw);
+ if (rumor.pubkey !== seal.pubkey) return null;
+ if (rumor.id !== getEventHash({ ...rumor, tags: rumor.tags || [] })) return null;
+ return { rumor, author: seal.pubkey, seal, raw };
+ } catch {
+ return null;
+ }
+};
+
+// millisecond ordering: created_at * 1000 + ms tag (CORD-02 §4)
+export const eventMs = (rumor) => {
+ let ms = +(rumor.tags?.find((t) => t[0] === "ms")?.[1] ?? 0);
+ if (!(ms >= 0 && ms <= 999)) return null;
+ return rumor.created_at * 1000 + ms;
+};
+
+export const msTags = (nowMs) => {
+ let created_at = Math.floor(nowMs / 1000);
+ return { created_at, ms: ["ms", String(nowMs % 1000)] };
+};
+
+// ---- CORD-04 editions ----
+
+// edition_hash: sha256 over the length-prefixed, domain-separated preimage
+export const editionHash = (eidHex, version, prevHex, contentStr) => {
+ let label = utf8("vector-community/v1/edition");
+ let content = utf8(contentStr);
+ return bytesToHex(
+ sha256(
+ concat(
+ be64(label.length), label,
+ hexToBytes(eidHex),
+ be64(version),
+ prevHex ? concat(new Uint8Array([1]), hexToBytes(prevHex)) : concat(new Uint8Array([0]), ZERO32),
+ be64(content.length), content
+ )
+ )
+ );
+};
+
+export const makeEdition = ({ vsk, eid, version, prev, content, vac }, nowMs) => {
+ let { created_at, ms } = msTags(nowMs);
+ let tags = [["vsk", String(vsk)], ["eid", eid], ["ev", String(version)], ms];
+ if (prev) tags.push(["ep", prev]);
+ if (vac) tags.push(["vac", ...vac]);
+ return { kind: 3308, content, tags, created_at };
+};
+
+// Fold control-plane rumors into per-entity heads.
+// Authority: owner-rooted; we accept owner-signed editions always, and
+// non-owner editions only when a fold of grants/roles authorizes them.
+// Returns { metadata, channels: Map(channelId -> meta), roles, grants, banned:Set }.
+export const foldControl = (entries, { ownerHex, cid }) => {
+ // entries: [{ rumor, author }] — group by (vsk, eid), take highest intact version
+ let byEntity = new Map();
+ for (let e of entries) {
+ let tag = (k) => e.rumor.tags?.find((t) => t[0] === k)?.[1];
+ let vsk = tag("vsk"), eid = tag("eid"), ev = +tag("ev");
+ if (vsk === undefined || !eid || !(ev >= 1)) continue;
+ let key = vsk + ":" + eid;
+ let cur = byEntity.get(key);
+ // Highest version wins; ties: authority first (owner beats non-owner), then lower rumor id.
+ let better =
+ !cur ||
+ ev > cur.ev ||
+ (ev === cur.ev &&
+ ((e.author === ownerHex) > (cur.author === ownerHex) ||
+ (e.author === ownerHex) === (cur.author === ownerHex) && e.rumor.id < cur.rumor.id));
+ if (better) byEntity.set(key, { ...e, vsk, eid, ev });
+ }
+
+ let state = { metadata: null, channels: new Map(), roles: new Map(), grants: new Map(), banned: new Set() };
+ let parse = (e) => { try { return JSON.parse(e.rumor.content); } catch { return null; } };
+
+ // Roles and grants first so later authority checks could use them; for now
+ // hal only honors owner-signed authority (the coinos community's owner is
+ // its sole admin) plus grants the owner signed.
+ for (let e of byEntity.values()) {
+ if (e.author !== ownerHex) continue; // non-owner authority: future work (vac/roster walk)
+ let c = parse(e);
+ if (e.vsk === "1" && c) state.roles.set(e.eid, c);
+ else if (e.vsk === "3" && c) state.grants.set(e.eid, c);
+ }
+ for (let e of byEntity.values()) {
+ if (e.author !== ownerHex) continue;
+ let c = parse(e);
+ if (e.vsk === "0" && c && e.eid === cid) state.metadata = c;
+ else if (e.vsk === "2" && c) state.channels.set(e.eid, c);
+ else if (e.vsk === "4" && Array.isArray(c)) state.banned = new Set(c);
+ }
+ for (let [, ch] of [...state.channels]) if (ch.deleted) state.channels.delete(ch.channel_id);
+ return state;
+};
+
+// ---- CORD-02 §5 guestbook fold ----
+// entries: [{ rumor, author }], returns Map(npub -> { state: 'join'|'leave'|'kick', t })
+export const foldGuestbook = (entries, { nowMs, banned = new Set() } = {}) => {
+ let members = new Map();
+ let consider = (npub, state, t, id) => {
+ let cur = members.get(npub);
+ if (!cur || t > cur.t || (t === cur.t && id < cur.id)) members.set(npub, { state, t, id });
+ };
+ for (let { rumor, author } of entries) {
+ let t = eventMs(rumor);
+ if (t === null || (nowMs && t > nowMs + 3600_000)) continue;
+ if (rumor.kind === 3306 && (rumor.content === "join" || rumor.content === "leave"))
+ consider(author, rumor.content, t, rumor.id);
+ // kicks (3309) and snapshots (3312): honored later with roster ranks; skipped for now
+ }
+ for (let b of banned) members.delete(b);
+ return members;
+};
+
+// Observed presence: any decrypted chat author newer than their latest leave re-enters (CORD-02 §5)
+export const observeAuthor = (members, author, t) => {
+ let cur = members.get(author);
+ if (!cur || (cur.state !== "join" && t > cur.t)) members.set(author, { state: "join", t, id: "" });
+};