diff options
| author | Adam Soltys <[email protected]> | 2026-09-09 23:06:35 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-09 23:06:35 -0700 |
| commit | 839ab937afbd0c68d1a4366e10145274b6f62d31 (patch) | |
| tree | 5802fb44898db984840a7eee238a4ecc5007dcd2 /src/sync-blob.js | |
| parent | 95cd3fa81097e288e4920bb5029cfd23f7cae98f (diff) | |
| download | coinosv3-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.js | 118 |
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; +} |
