diff --git a/js/nostr/encryptedSync.js b/js/nostr/encryptedSync.js new file mode 100644 index 00000000..af3c3538 --- /dev/null +++ b/js/nostr/encryptedSync.js @@ -0,0 +1,291 @@ +// Opt-in encrypted cross-login sync of bitvid settings (storage credentials, NWC +// wallet) published as NIP-78 app-data (kind 30078) *replaceable* events, +// encrypted to the user's own key. +// +// Security model (see todo item #15 — keep this in sync with the opt-in UX copy): +// - Content is NIP-44-encrypted to the user's OWN pubkey (NIP-04 fallback only if +// the signer lacks NIP-44, e.g. some NIP-46 remotes). The blob lives on PUBLIC +// relays: encryption hides the contents, NOT the fact that this pubkey stores +// bitvid settings. +// - Anyone who controls the user's Nostr key can decrypt these — the same trust +// root as everything else they sign. The opt-in copy must say so. +// - NWC URIs are bearer SPENDING secrets; callers must gate that sync behind an +// explicit warning. +// - Never log decrypted payloads or ciphertext. +// - Publish to WRITE relays so the update reaches everywhere; reads take the +// NEWEST event per d-tag so a stale device can't clobber newer settings. +// - created_at is forced strictly-newer than any known prior event so a replace +// actually wins. +// +// This module is pure / dependency-injected so it can be unit-tested without a +// live signer or relay pool. + +export const APP_DATA_KIND = 30078; +export const ENCRYPTED_SYNC_VERSION = 1; + +function nowSeconds(now) { + const ms = typeof now === "function" ? now() : Date.now(); + return Math.floor(ms / 1000); +} + +function normalizeDTag(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function normalizePubkey(value) { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +async function encryptToSelf(signer, pubkey, plaintext) { + const caps = signer.capabilities || { nip04: true, nip44: true }; + if (caps.nip44 && typeof signer.nip44Encrypt === "function") { + try { + const ciphertext = await signer.nip44Encrypt(pubkey, plaintext); + return { alg: "nip44", ciphertext }; + } catch (err) { + // Fall through to NIP-04 for signers whose NIP-44 path is unavailable. + } + } + if (caps.nip04 && typeof signer.nip04Encrypt === "function") { + const ciphertext = await signer.nip04Encrypt(pubkey, plaintext); + return { alg: "nip04", ciphertext }; + } + throw new Error("Signer does not support encryption (NIP-04 or NIP-44)."); +} + +async function decryptFromSelf(signer, pubkey, alg, ciphertext) { + const caps = signer.capabilities || { nip04: true, nip44: true }; + if (alg === "nip44" && caps.nip44 && typeof signer.nip44Decrypt === "function") { + return signer.nip44Decrypt(pubkey, ciphertext); + } + if ((alg === "nip04" || !alg) && caps.nip04 && typeof signer.nip04Decrypt === "function") { + return signer.nip04Decrypt(pubkey, ciphertext); + } + throw new Error(`Signer cannot decrypt ${alg || "nip04"} content.`); +} + +// Replaceable semantics: of all events a relay returns for a (kind, author, d), +// only the newest created_at is authoritative. +function selectNewest(events) { + let newest = null; + for (const ev of Array.isArray(events) ? events : []) { + if (!ev || typeof ev !== "object") { + continue; + } + const createdAt = Number(ev.created_at); + if (!Number.isFinite(createdAt)) { + continue; + } + if (!newest || createdAt > Number(newest.created_at)) { + newest = ev; + } + } + return newest; +} + +export function createEncryptedSyncManager(deps = {}) { + const { + getActivePubkey, + getSigner, + getWriteRelays, + getReadRelays, + getPool, + publishEventToRelays, + summarizePublishResults, + signEvent, + now, + } = deps; + + function resolvePubkey() { + return normalizePubkey( + typeof getActivePubkey === "function" ? getActivePubkey() : "", + ); + } + + function resolveSigner() { + return typeof getSigner === "function" ? getSigner() : null; + } + + function isAvailable() { + const pubkey = resolvePubkey(); + const signer = resolveSigner(); + if (!pubkey || !signer) { + return false; + } + const caps = signer.capabilities || { nip04: true, nip44: true }; + return Boolean( + (caps.nip44 && typeof signer.nip44Encrypt === "function") || + (caps.nip04 && typeof signer.nip04Encrypt === "function"), + ); + } + + function resolveCreatedAt(options) { + return Math.max( + nowSeconds(now), + (Number(options?.afterCreatedAt) || 0) + 1, + ); + } + + async function publishTemplate(template) { + let signed; + try { + signed = await signEvent(template); + } catch (err) { + return { ok: false, error: "sign-failed", cause: err }; + } + const relays = + (typeof getWriteRelays === "function" ? getWriteRelays() : []) || []; + const pool = typeof getPool === "function" ? getPool() : null; + if (!pool || !relays.length) { + return { ok: false, error: "no-relays" }; + } + const results = await publishEventToRelays(pool, relays, signed); + const { accepted, failed } = summarizePublishResults(results); + return { + ok: accepted.length > 0, + accepted: accepted.length, + total: accepted.length + failed.length, + event: signed, + createdAt: template.created_at, + }; + } + + async function push(dTag, payload, options = {}) { + const tag = normalizeDTag(dTag); + if (!tag) { + return { ok: false, error: "missing-d-tag" }; + } + const pubkey = resolvePubkey(); + const signer = resolveSigner(); + if (!pubkey || !signer) { + return { ok: false, error: "not-authenticated" }; + } + + let plaintext; + try { + plaintext = JSON.stringify(payload ?? null); + } catch (err) { + return { ok: false, error: "payload-not-serializable" }; + } + + let envelope; + try { + envelope = await encryptToSelf(signer, pubkey, plaintext); + } catch (err) { + return { ok: false, error: "encrypt-failed", cause: err }; + } + + const template = { + kind: APP_DATA_KIND, + pubkey, + created_at: resolveCreatedAt(options), + tags: [ + ["d", tag], + ["client", "bitvid"], + ], + content: JSON.stringify({ + v: ENCRYPTED_SYNC_VERSION, + alg: envelope.alg, + data: envelope.ciphertext, + }), + }; + + return publishTemplate(template); + } + + async function pull(dTag) { + const tag = normalizeDTag(dTag); + if (!tag) { + return { found: false, error: "missing-d-tag" }; + } + const pubkey = resolvePubkey(); + const signer = resolveSigner(); + if (!pubkey || !signer) { + return { found: false, error: "not-authenticated" }; + } + + const relays = + (typeof getReadRelays === "function" ? getReadRelays() : []) || []; + const pool = typeof getPool === "function" ? getPool() : null; + if (!pool || !relays.length) { + return { found: false, error: "no-relays" }; + } + + let events = []; + try { + events = await pool.list(relays, [ + { kinds: [APP_DATA_KIND], authors: [pubkey], "#d": [tag], limit: 20 }, + ]); + } catch (err) { + return { found: false, error: "fetch-failed", cause: err }; + } + + const newest = selectNewest(Array.isArray(events) ? events.flat() : []); + if (!newest) { + return { found: false }; + } + + const rawContent = + typeof newest.content === "string" ? newest.content.trim() : ""; + if (!rawContent) { + return { found: false, cleared: true, createdAt: newest.created_at }; + } + + let parsed; + try { + parsed = JSON.parse(rawContent); + } catch (err) { + return { found: false, error: "corrupt-content", createdAt: newest.created_at }; + } + if (parsed && parsed.cleared === true) { + return { found: false, cleared: true, createdAt: newest.created_at }; + } + + let plaintext; + try { + plaintext = await decryptFromSelf(signer, pubkey, parsed?.alg, parsed?.data); + } catch (err) { + return { found: false, error: "decrypt-failed", cause: err }; + } + + let payload; + try { + payload = JSON.parse(plaintext); + } catch (err) { + return { found: false, error: "corrupt-payload" }; + } + + return { found: true, payload, createdAt: newest.created_at, event: newest }; + } + + async function clear(dTag, options = {}) { + const tag = normalizeDTag(dTag); + if (!tag) { + return { ok: false, error: "missing-d-tag" }; + } + const pubkey = resolvePubkey(); + const signer = resolveSigner(); + if (!pubkey || !signer) { + return { ok: false, error: "not-authenticated" }; + } + + // Publish a cleared marker (NOT empty content — some relays reject empty) so + // the cleared state propagates and a stale older event can't win on read. + const template = { + kind: APP_DATA_KIND, + pubkey, + created_at: resolveCreatedAt(options), + tags: [ + ["d", tag], + ["client", "bitvid"], + ], + content: JSON.stringify({ v: ENCRYPTED_SYNC_VERSION, cleared: true }), + }; + + return publishTemplate(template); + } + + return { isAvailable, push, pull, clear, APP_DATA_KIND }; +} + +export default createEncryptedSyncManager; diff --git a/tests/encrypted-sync.test.mjs b/tests/encrypted-sync.test.mjs new file mode 100644 index 00000000..75ad25be --- /dev/null +++ b/tests/encrypted-sync.test.mjs @@ -0,0 +1,175 @@ +// Opt-in encrypted settings sync (todo #15): bitvid settings are published as +// NIP-78 (kind 30078) replaceable events, encrypted to the user's own key, read +// back newest-per-d-tag. These scenarios assert the observable boundary outcomes: +// - a pushed secret round-trips through encrypt -> relay -> decrypt +// - the secret is NEVER stored in cleartext on the relay (encryption is real) +// - the NEWEST event wins (a stale device cannot clobber newer settings) +// - clear() makes a subsequent pull report "no data" (cleared) +// - NIP-04 is used when the signer lacks NIP-44 +// +// Uses the REAL publishEventToRelays/summarizePublishResults so the publish +// boundary is exercised, with a faithful in-memory relay + a reversible fake +// signer (so "encryption" is observable but not identity passthrough). + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { createEncryptedSyncManager } from "../js/nostr/encryptedSync.js"; +import { + publishEventToRelays, + summarizePublishResults, +} from "../js/nostrPublish.js"; + +const PUBKEY = "d".repeat(64); +const D_TAG = "bitvid:storage-connections"; + +// Reversible, non-identity "encryption": base64 so the plaintext secret is not a +// literal substring of the ciphertext. Tagged by scheme so we can prove which +// path (nip44 vs nip04) was used. +function makeSigner({ nip44 = true } = {}) { + const b64 = (s) => Buffer.from(s, "utf8").toString("base64"); + const unb64 = (s) => Buffer.from(s, "base64").toString("utf8"); + const enc = (scheme) => async (_pk, text) => `${scheme}|${b64(text)}`; + const dec = (scheme) => async (_pk, cipher) => { + const [usedScheme, payload] = String(cipher).split("|"); + assert.equal(usedScheme, scheme, "decrypt scheme must match encrypt scheme"); + return unb64(payload); + }; + const signer = { + capabilities: { nip04: true, nip44 }, + nip04Encrypt: enc("nip04"), + nip04Decrypt: dec("nip04"), + }; + if (nip44) { + signer.nip44Encrypt = enc("nip44"); + signer.nip44Decrypt = dec("nip44"); + } + return signer; +} + +function makeRelay() { + const events = []; + return { + events, + publish(_urls, event) { + events.push(event); + return Promise.resolve(); // thenable -> publishEventToRelay success path + }, + async list(_relays, filters) { + const f = filters[0]; + return events.filter( + (e) => + f.kinds.includes(e.kind) && + f.authors.includes(e.pubkey) && + Array.isArray(e.tags) && + e.tags.some((t) => t[0] === "d" && f["#d"].includes(t[1])), + ); + }, + }; +} + +function makeManager({ nip44 = true } = {}) { + const relay = makeRelay(); + const clock = { ms: 1_700_000_000_000 }; + const signer = makeSigner({ nip44 }); + const manager = createEncryptedSyncManager({ + getActivePubkey: () => PUBKEY, + getSigner: () => signer, + getWriteRelays: () => ["wss://write.relay"], + getReadRelays: () => ["wss://read.relay"], + getPool: () => relay, + publishEventToRelays, + summarizePublishResults, + signEvent: async (tpl) => ({ + ...tpl, + id: `id-${tpl.created_at}-${relay.events.length}`, + sig: "sig", + }), + now: () => clock.ms, + }); + return { manager, relay, clock }; +} + +test("push -> pull round-trips a secret without ever storing it in cleartext", async () => { + const { manager, relay } = makeManager(); + const secret = "SUPER-SECRET-R2-ACCESS-KEY-xyz"; + const payload = { accessKeyId: secret, bucket: "my-bucket" }; + + const pushed = await manager.push(D_TAG, payload); + assert.equal(pushed.ok, true, "push must succeed when a relay accepts"); + assert.ok(pushed.accepted >= 1); + + // Anti-cheat: the secret must NOT appear in cleartext anywhere on the relay. + const stored = relay.events[0]; + assert.equal(stored.kind, 30078); + assert.ok( + !stored.content.includes(secret), + "the raw secret must never be stored in cleartext on the relay", + ); + assert.ok( + !stored.content.includes("my-bucket"), + "plaintext metadata (bucket) must be encrypted too", + ); + + const pulled = await manager.pull(D_TAG); + assert.equal(pulled.found, true, "pull must find the pushed note"); + assert.deepEqual( + pulled.payload, + payload, + "decrypted payload must match what was pushed", + ); +}); + +test("the NEWEST event wins — a stale copy cannot clobber newer settings", async () => { + const { manager, relay, clock } = makeManager(); + + await manager.push(D_TAG, { generation: "old", bucket: "old-bucket" }); + clock.ms += 120_000; // 2 minutes later + await manager.push(D_TAG, { generation: "new", bucket: "new-bucket" }); + + // Both events exist on the relay (replaceable selection happens on read). + assert.equal(relay.events.length, 2, "both copies are present on the relay"); + + const pulled = await manager.pull(D_TAG); + assert.equal(pulled.found, true); + assert.equal( + pulled.payload.generation, + "new", + "pull must return the newest event's payload, not a stale one", + ); +}); + +test("clear() makes a subsequent pull report cleared / no data", async () => { + const { manager, clock } = makeManager(); + await manager.push(D_TAG, { secret: "to-be-wiped" }); + + clock.ms += 60_000; + const cleared = await manager.clear(D_TAG); + assert.equal(cleared.ok, true, "clear must publish successfully"); + + const pulled = await manager.pull(D_TAG); + assert.equal(pulled.found, false, "cleared settings must not be found"); + assert.equal(pulled.cleared, true, "pull must report the cleared marker"); +}); + +test("falls back to NIP-04 when the signer lacks NIP-44", async () => { + const { manager, relay } = makeManager({ nip44: false }); + const payload = { nwcUri: "nostr+walletconnect://deadbeef" }; + + const pushed = await manager.push("bitvid:nwc", payload); + assert.equal(pushed.ok, true); + + const envelope = JSON.parse(relay.events[0].content); + assert.equal(envelope.alg, "nip04", "must record the nip04 scheme actually used"); + + const pulled = await manager.pull("bitvid:nwc"); + assert.deepEqual(pulled.payload, payload); +}); + +test("isAvailable() is false without a signer or pubkey", async () => { + const manager = createEncryptedSyncManager({ + getActivePubkey: () => "", + getSigner: () => null, + }); + assert.equal(manager.isAvailable(), false); +});