Files
sattle/src/lnurlcash/nostr/events.ts
T
2026-09-05 18:30:35 +02:00

237 lines
9.0 KiB
TypeScript

// The pure half of nostr backup: backup-key derivation and the backup
// event codec - build, sign, verify, decrypt - with no relay I/O.
//
// The backup key is derived, not generated: sha256 over the linking key
// plus a fixed context string, the same construction as keys.ts's
// deriveBearerAesKey. The seed phrase itself is never stored, so the
// derivation starts from the linking key - deterministic all the way down,
// which is what makes restore-from-seed work on a fresh device.
//
// Three d-tags carry three payloads in separate replaceable slots, so a
// notes publish never clobbers settings:
// notes - the funds projection: encrypted bearer records exactly as
// they sit in the funds document (each note is already an
// AES-GCM ciphertext under the seed-derived bearer key, so the
// blob goes up as-is; the linking key itself is NEVER part of a
// payload - the seed phrase is its recovery path) plus the
// per-host BIP-32 next-unused counters, so a restored device
// never reissues a burned index. Pending journal state is
// device-local recovery data and is never published.
// mints - the trusted-mint registry
// settings - plaintext wallet settings
import { sha256 } from '@noble/hashes/sha2.js';
import { utf8ToBytes } from '@noble/hashes/utils.js';
import type { Event as NostrEvent } from 'nostr-tools/core';
import { finalizeEvent, getPublicKey, verifyEvent } from 'nostr-tools/pure';
import { v2 as nip44v2 } from 'nostr-tools/nip44';
import type { EncryptedBearerRecord } from '../storage/bearers';
import type { TrustedMint } from '../trustedMints';
import type { WalletSettings } from '../storage/settings';
export type { NostrEvent };
// addressable (parametrized-replaceable) app-data event - relays keep only
// the newest event per (pubkey, kind, d-tag)
export const BACKUP_EVENT_KIND = 30078;
export type BackupPart = 'notes' | 'mints' | 'settings';
export const BACKUP_PARTS: readonly BackupPart[] = ['notes', 'mints', 'settings'];
// the backup key is dedicated to this wallet (derived with a
// sattle-specific context, see below), so its pubkey is ours alone and the
// d-tags need no further namespacing
export const BACKUP_D_TAGS: Record<BackupPart, string> = {
notes: 'notes',
mints: 'mints',
settings: 'settings',
};
// the notes payload: bearers plus the counter map, from one consistent
// funds-document read
export type NotesBackupPayload = {
bearers: EncryptedBearerRecord[];
nextByHost: Record<string, number>;
};
// the decrypted payload of each part, without its envelope
export type BackupPartPayload = {
notes: NotesBackupPayload;
mints: TrustedMint[];
settings: WalletSettings;
};
const BACKUP_KEY_CONTEXT = 'sattle-nostr-backup-v1';
// Deterministic: sha256(linking key || context), mirroring keys.ts's
// deriveBearerAesKey. The result is a secp256k1 secret key used ONLY for
// backup - it signs and decrypts backup events, nothing else.
export const deriveBackupKey = (linkingPrivKey: Uint8Array): Uint8Array =>
sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BACKUP_KEY_CONTEXT)]));
// the x-only nostr pubkey identifying this wallet's backup events
export const backupPubkey = (secretKey: Uint8Array): string => getPublicKey(secretKey);
// NIP-44 "self-DM": the conversation key between the backup key and its own
// pubkey - decryptable by the seed holder and nobody else
const selfConversationKey = (secretKey: Uint8Array): Uint8Array =>
nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey));
// a relay could serve a multi-megabyte content string; JSON.parse and
// NIP-44 decrypt of that would hang the tab. A real backup is a handful of
// kilobytes per part - this is far beyond generous (applyBackup's own
// per-record bounds still apply on top after decrypt)
const MAX_BACKUP_CONTENT_CHARS = 16 * 1024 * 1024;
export const dTagOf = (event: NostrEvent): string =>
event.tags.find((t) => t[0] === 'd')?.[1] ?? '';
// per-part envelope versions: the notes part went v2 with the funds
// document (counters joined the payload - a breaking change); mints and
// settings envelopes are unchanged
const PART_ENVELOPE_VERSION: Record<BackupPart, number> = {
notes: 2,
mints: 1,
settings: 1,
};
const envelopeFor = (part: BackupPart, payload: BackupPartPayload[BackupPart]): string => {
switch (part) {
case 'notes': {
const notes = payload as NotesBackupPayload;
return JSON.stringify({ version: 2, bearers: notes.bearers, nextByHost: notes.nextByHost });
}
case 'mints':
return JSON.stringify({ version: 1, trustedMints: payload });
case 'settings':
return JSON.stringify({ version: 1, settings: payload });
}
};
// signs one addressable backup event for a single part
export const buildBackupEvent = <P extends BackupPart>(
secretKey: Uint8Array,
part: P,
payload: BackupPartPayload[P],
createdAt: number = Math.floor(Date.now() / 1000),
): NostrEvent =>
finalizeEvent(
{
kind: BACKUP_EVENT_KIND,
created_at: createdAt,
tags: [['d', BACKUP_D_TAGS[part]]],
content: nip44v2.encrypt(envelopeFor(part, payload), selfConversationKey(secretKey)),
},
secretKey,
);
// one event per part present in `parts`, all sharing one timestamp
export const buildBackupEvents = (
secretKey: Uint8Array,
parts: Partial<BackupPartPayload>,
createdAt?: number,
): NostrEvent[] => {
const at = createdAt ?? Math.floor(Date.now() / 1000);
const events: NostrEvent[] = [];
for (const part of BACKUP_PARTS) {
const payload = parts[part];
if (payload === undefined) continue;
events.push(buildBackupEvent(secretKey, part, payload, at));
}
return events;
};
export type ParsedBackupEvent =
| { part: 'notes'; bearers: EncryptedBearerRecord[]; nextByHost: Record<string, number> }
| { part: 'mints'; trustedMints: TrustedMint[] }
| { part: 'settings'; settings: WalletSettings };
// Light shape checks so parse returns typed values; the strict bounds
// (record counts, field lengths, counter ranges and caps) are enforced by
// applyBackup / commitFundsRestore on the restore path, same as file
// backups.
const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null => {
if (typeof data !== 'object' || data === null) return null;
const envelope = data as Record<string, unknown>;
if (envelope.version !== PART_ENVELOPE_VERSION[dTag]) return null;
switch (dTag) {
case 'notes': {
if (!Array.isArray(envelope.bearers)) return null;
const bearers = envelope.bearers as unknown[];
if (
!bearers.every(
(r) =>
typeof (r as EncryptedBearerRecord)?.id === 'string' &&
typeof (r as EncryptedBearerRecord)?.iv === 'string' &&
typeof (r as EncryptedBearerRecord)?.ciphertext === 'string',
)
) {
return null;
}
if (typeof envelope.nextByHost !== 'object' || envelope.nextByHost === null) return null;
const counters = envelope.nextByHost as Record<string, unknown>;
if (!Object.values(counters).every((v) => typeof v === 'number')) return null;
return {
part: 'notes',
bearers: bearers as EncryptedBearerRecord[],
nextByHost: counters as Record<string, number>,
};
}
case 'mints': {
if (!Array.isArray(envelope.trustedMints)) return null;
const mints = envelope.trustedMints as unknown[];
if (
!mints.every(
(m) =>
typeof (m as TrustedMint)?.server === 'string' &&
typeof (m as TrustedMint)?.mintPubkey === 'string',
)
) {
return null;
}
return { part: 'mints', trustedMints: mints as TrustedMint[] };
}
case 'settings': {
if (typeof envelope.settings !== 'object' || envelope.settings === null) {
return null;
}
const settings = envelope.settings as Record<string, unknown>;
if (settings.defaultMint !== undefined && typeof settings.defaultMint !== 'string') {
return null;
}
return { part: 'settings', settings: settings as WalletSettings };
}
}
};
// Validates and decrypts one backup event. Returns null for anything that
// isn't a genuine, untampered backup of THIS key: wrong kind, wrong d-tag,
// wrong author, bad signature, undecryptable or malformed payload. Callers
// skip nulls - one junk event must never sink a restore.
export const parseBackupEvent = (
secretKey: Uint8Array,
event: NostrEvent,
): ParsedBackupEvent | null => {
if (event.kind !== BACKUP_EVENT_KIND) return null;
if (event.pubkey !== getPublicKey(secretKey)) return null;
const dTag = dTagOf(event);
if (dTag !== 'notes' && dTag !== 'mints' && dTag !== 'settings') return null;
if (!verifyEvent(event)) return null;
if (event.content.length > MAX_BACKUP_CONTENT_CHARS) return null;
let plaintext: string;
try {
plaintext = nip44v2.decrypt(event.content, selfConversationKey(secretKey));
} catch {
return null;
}
let data: unknown;
try {
data = JSON.parse(plaintext);
} catch {
return null;
}
return parsePayload(dTag, data);
};