feat(sync): encrypted cross-login settings sync core (NIP-78, opt-in)

Shared core for the opt-in encrypted sync of bitvid settings (storage creds, NWC
wallet) recorded in todo #15. Publishes settings as NIP-78 app-data (kind 30078)
REPLACEABLE events, NIP-44-encrypted to the user's own key (NIP-04 fallback when
the signer lacks NIP-44).

js/nostr/encryptedSync.js — createEncryptedSyncManager(deps):
  - isAvailable(): signer present + can encrypt to self
  - push(dTag, payload): encrypt-to-self -> publish to WRITE relays, created_at
    forced strictly-newer so a replace actually wins
  - pull(dTag): read newest-per-d-tag from read relays -> decrypt (a stale device
    cannot clobber newer settings)
  - clear(dTag): publish a cleared marker so the wipe propagates (empty-
    replaceable-publish lesson)

Dependency-injected (pure) so it unit-tests without a live signer/relay. No UI
wiring yet — no behavior change. Storage pane wiring is the next increment.

Security: encryption hides contents but not that this pubkey stores bitvid
settings (public relays); anyone with the user's key can decrypt; NWC URIs are
spending secrets (caller must gate behind a warning); never log decrypted data.

tests/encrypted-sync.test.mjs — round-trip, no-cleartext-leak, newest-wins,
clear, NIP-04 fallback. Mutation-verified (newest-selection + encryption-bypass
both fail the suite).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-21 16:52:44 -04:00
1 parent ebacf223c8
commit 0abb82b933
2 files changed
+466

No files matched your search

+291
View File
@@ -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;
+175
View File
@@ -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);
});