summaryrefslogtreecommitdiffstats
path: root/src/sync-blob.js
blob: c44ab300530287c5e37485ee3b879d0bf0582980 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
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;
}