mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
Completes todo #15. On login, if an encrypted copy of the user's storage and/or wallet settings exists on their Nostr account — and this device hasn't already opted in — offer to restore it. - js/nostr/encryptedSync.js: exists(dTag) — cheap list-only existence peek (NO decrypt, so it doesn't spend the signer budget); decryption only on accept. - storage/walletSyncService: hasRemote() wrappers over exists(). - js/services/settingsRestorePrompt.js: one-time per-pubkey offer. Only prompts for items with a remote copy not already enabled locally; if nothing remote exists it does NOT consume the one-time offer (a note published later can still prompt next login). On accept, pulls + applies. - app.js handleLoginModalSuccess: fires maybeOfferSettingsRestore(pubkey) after login, deferred ~2s so relays connect; best-effort, never blocks login; toasts on restore. tests/settings-restore-prompt.test.mjs: offer+restore-only-remote-items, decline, prompt-once, skip-already-enabled, don't-consume-when-empty. Mutation-verified. Co-Authored-By: Claude Opus 4.8 <[email protected]>
336 lines
10 KiB
JavaScript
336 lines
10 KiB
JavaScript
// 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,
|
|
listEvents,
|
|
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() : []) || [];
|
|
if (typeof listEvents !== "function" || !relays.length) {
|
|
return { found: false, error: "no-relays" };
|
|
}
|
|
|
|
// Reads go through the injected listEvents (subscription manager in prod) —
|
|
// never pool.list directly (lint:pool-access).
|
|
let events = [];
|
|
try {
|
|
events = await listEvents(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 };
|
|
}
|
|
|
|
// Cheap existence peek for the login-restore prompt: list-only, NO decrypt
|
|
// (does not spend the signer's decrypt budget). Returns whether a non-cleared
|
|
// note is present for the d-tag.
|
|
async function exists(dTag) {
|
|
const tag = normalizeDTag(dTag);
|
|
const pubkey = resolvePubkey();
|
|
if (!tag || !pubkey) {
|
|
return { exists: false };
|
|
}
|
|
const relays =
|
|
(typeof getReadRelays === "function" ? getReadRelays() : []) || [];
|
|
if (typeof listEvents !== "function" || !relays.length) {
|
|
return { exists: false };
|
|
}
|
|
let events = [];
|
|
try {
|
|
events = await listEvents(relays, [
|
|
{ kinds: [APP_DATA_KIND], authors: [pubkey], "#d": [tag], limit: 20 },
|
|
]);
|
|
} catch (err) {
|
|
return { exists: false, error: "fetch-failed" };
|
|
}
|
|
const newest = selectNewest(Array.isArray(events) ? events.flat() : []);
|
|
if (!newest) {
|
|
return { exists: false };
|
|
}
|
|
const rawContent =
|
|
typeof newest.content === "string" ? newest.content.trim() : "";
|
|
if (!rawContent) {
|
|
return { exists: false, cleared: true };
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(rawContent);
|
|
if (parsed && parsed.cleared === true) {
|
|
return { exists: false, cleared: true };
|
|
}
|
|
} catch (err) {
|
|
// Unparseable content still counts as "something is there".
|
|
}
|
|
return { exists: true, createdAt: newest.created_at };
|
|
}
|
|
|
|
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, exists, clear, APP_DATA_KIND };
|
|
}
|
|
|
|
export default createEncryptedSyncManager;
|