mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
ebacf223c8
commit
0abb82b933
2 files changed
+466
No files matched your search
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user