aboutsummaryrefslogtreecommitdiffstats
path: root/src/sync-blob.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-09 23:06:35 -0700
committerAdam Soltys <[email protected]>2026-09-09 23:06:35 -0700
commit839ab937afbd0c68d1a4366e10145274b6f62d31 (patch)
tree5802fb44898db984840a7eee238a4ecc5007dcd2 /src/sync-blob.js
parent95cd3fa81097e288e4920bb5029cfd23f7cae98f (diff)
downloadcoinosv3-839ab937afbd0c68d1a4366e10145274b6f62d31.tar.gz
coinosv3-839ab937afbd0c68d1a4366e10145274b6f62d31.zip
Sync: oversized domains ride Blossom blobs — plus a coinos-hosted server.
A wallet with deep Ark coins had a 633KB ark sync domain against the relay's 40KB budget: the publish silently failed forever, the slot stayed stale, and every boot retried. Relays can't carry it (64KB event cap) and neither can NIP-44 (64KB plaintext), so a domain over budget now goes to Blossom (nostr blob storage, BUD-01/02/06/11/12): sealed into an envelope ("CSB1" + xchacha20poly1305 under an HKDF of the wallet's NIP-44 self-key), uploaded to the configured servers, and the kind-30078 event carries only { blob: { sha256, size, servers } }. Readers resolve pointers from their own outbox copy first, else from the pointer's servers plus the configured list, hash-verified. Small domains are unchanged. The outbox uploads before it publishes (upload/delete authorizations are kind-24242 events signed at enqueue time, so delivery after logout still holds no key), keeps a local copy for recovery, and deletes superseded blobs once the replacement is acknowledged. Servers: Settings → Nostr → Sync storage (any Blossom server; a typed list is kept), else the user's BUD-03 list (kind 10063) with the coinos server as mirror, else blossom.coinos.io. blossom/server.js is that default: a Bun Blossom server that stores ONLY these envelopes (magic check → 415 for anything else), serves every blob as an octet-stream attachment with nosniff, requires signed auth for upload/list/delete, and enforces per-blob size, per-key quota (oldest evicted), rate limits, a disk cap and unused-blob expiry — it cannot serve as a file host. tools/sync-blob-test.js spawns it and covers the policy, the outbox flow and a fresh device resolving a pointer. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE
Diffstat (limited to 'src/sync-blob.js')
-rw-r--r--src/sync-blob.js118
1 files changed, 118 insertions, 0 deletions
diff --git a/src/sync-blob.js b/src/sync-blob.js
new file mode 100644
index 0000000..c44ab30
--- /dev/null
+++ b/src/sync-blob.js
@@ -0,0 +1,118 @@
+// Oversized sync domains ride Blossom (nostr blob storage) instead of a relay
+// event: a relay caps events at 64KB and NIP-44 caps plaintext at 64KB, but a
+// deep Ark coin set is hundreds of KB. The domain is sealed into an envelope
+// (magic + xchacha20poly1305 under a key derived from the wallet's own NIP-44
+// self-key), uploaded to one or more Blossom servers, and the kind-30078
+// event carries only { blob: { sha256, size, servers } }. Content addressing
+// means any server can hold the blob — users can self-host or pick another
+// provider in Settings → Nostr, and the coinos server accepts nothing but
+// these envelopes (see blossom/server.js).
+
+import { xchacha20poly1305 } from '@noble/ciphers/chacha.js';
+import { hkdf } from '@noble/hashes/hkdf';
+import { sha256 } from '@noble/hashes/sha256';
+import { bytesToHex, randomBytes } from '@noble/hashes/utils';
+import { base64 } from '@scure/base';
+import { finalizeEvent } from 'nostr-tools/pure';
+
+export const BLOB_MAGIC = new TextEncoder().encode('CSB1');
+export const DEFAULT_BLOSSOM_SERVERS = ['https://blossom.coinos.io'];
+const CONFIG_KEY = 'btc-wallet-blossom';
+const NONCE_LEN = 24;
+
+// ---- server list ----------------------------------------------------------
+export const normalizeServer = (s) => {
+ const u = String(s || '').trim().replace(/\/+$/, '');
+ return /^https?:\/\/[^\s/]+$/i.test(u) ? u : null;
+};
+export function getBlossomConfig() {
+ try {
+ const c = JSON.parse(localStorage.getItem(CONFIG_KEY) || 'null');
+ if (c && Array.isArray(c.servers)) return { servers: c.servers.map(normalizeServer).filter(Boolean), manual: !!c.manual };
+ } catch {}
+ return { servers: DEFAULT_BLOSSOM_SERVERS.slice(), manual: false };
+}
+export function setBlossomConfig({ servers, manual = false }) {
+ const list = [...new Set((servers || []).map(normalizeServer).filter(Boolean))].slice(0, 6);
+ try { localStorage.setItem(CONFIG_KEY, JSON.stringify({ servers: list, manual })); } catch {}
+ return list;
+}
+// Where to upload: the configured list, else the default.
+export const blossomServers = () => {
+ const { servers } = getBlossomConfig();
+ return servers.length ? servers : DEFAULT_BLOSSOM_SERVERS.slice();
+};
+
+// ---- envelope ------------------------------------------------------------
+// ck is the wallet's NIP-44 self conversation key (32 bytes, seed-derived) —
+// the same secret that already protects every relay snapshot.
+const blobKey = (ck) => hkdf(sha256, ck, undefined, 'coinos-sync-blob-v1', 32);
+export const sha256Hex = (bytes) => bytesToHex(sha256(bytes));
+
+export function sealBlob(ck, text) {
+ const nonce = randomBytes(NONCE_LEN);
+ const ct = xchacha20poly1305(blobKey(ck), nonce).encrypt(new TextEncoder().encode(text));
+ const out = new Uint8Array(BLOB_MAGIC.length + NONCE_LEN + ct.length);
+ out.set(BLOB_MAGIC, 0); out.set(nonce, BLOB_MAGIC.length); out.set(ct, BLOB_MAGIC.length + NONCE_LEN);
+ return out;
+}
+export function openBlob(ck, bytes) {
+ for (let i = 0; i < BLOB_MAGIC.length; i++) if (bytes[i] !== BLOB_MAGIC[i]) throw new Error('not a sync envelope');
+ const nonce = bytes.subarray(BLOB_MAGIC.length, BLOB_MAGIC.length + NONCE_LEN);
+ const pt = xchacha20poly1305(blobKey(ck), nonce).decrypt(bytes.subarray(BLOB_MAGIC.length + NONCE_LEN));
+ return new TextDecoder().decode(pt);
+}
+
+// ---- Blossom auth (BUD-11): kind 24242, signed up front so the durable
+// outbox can deliver after logout without holding a key ------------------
+export function blobAuth(sk, verb, shas = [], { ttlSec = 7 * 86400, content } = {}) {
+ const now = Math.floor(Date.now() / 1000);
+ return finalizeEvent({
+ kind: 24242,
+ created_at: now - 5, // a client clock slightly ahead of the server's is not a rejection
+ content: content || `coinos wallet sync: ${verb}`,
+ tags: [['t', verb], ['expiration', String(now + ttlSec)], ...shas.map((x) => ['x', x])],
+ }, sk);
+}
+export const authHeader = (evt) => 'Nostr ' + base64.encode(new TextEncoder().encode(JSON.stringify(evt)));
+
+// ---- transport ---------------------------------------------------------
+const withTimeout = (ms) => {
+ const c = new AbortController();
+ const t = setTimeout(() => c.abort(), ms);
+ return { signal: c.signal, done: () => clearTimeout(t) };
+};
+export async function uploadBlob(server, bytes, auth, { timeoutMs = 30000 } = {}) {
+ const base = normalizeServer(server); if (!base) return false;
+ const t = withTimeout(timeoutMs);
+ try {
+ const r = await fetch(base + '/upload', {
+ method: 'PUT', signal: t.signal, body: bytes,
+ headers: { Authorization: authHeader(auth), 'Content-Type': 'application/octet-stream', 'X-SHA-256': sha256Hex(bytes) },
+ });
+ return r.status === 200 || r.status === 201;
+ } catch { return false; } finally { t.done(); }
+}
+export async function deleteBlob(server, sha, auth, { timeoutMs = 15000 } = {}) {
+ const base = normalizeServer(server); if (!base) return false;
+ const t = withTimeout(timeoutMs);
+ try {
+ const r = await fetch(`${base}/${sha}`, { method: 'DELETE', signal: t.signal, headers: { Authorization: authHeader(auth) } });
+ return r.ok || r.status === 404;
+ } catch { return false; } finally { t.done(); }
+}
+// First server that returns bytes hashing to `sha` wins; a mismatch is a
+// lying (or corrupted) server and is skipped like a miss.
+export async function fetchBlob(servers, sha, { timeoutMs = 30000 } = {}) {
+ for (const s of servers) {
+ const base = normalizeServer(s); if (!base) continue;
+ const t = withTimeout(timeoutMs);
+ try {
+ const r = await fetch(`${base}/${sha}`, { signal: t.signal });
+ if (!r.ok) continue;
+ const bytes = new Uint8Array(await r.arrayBuffer());
+ if (sha256Hex(bytes) === sha) return bytes;
+ } catch {} finally { t.done(); }
+ }
+ return null;
+}