mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
150 lines
5.6 KiB
TypeScript
150 lines
5.6 KiB
TypeScript
// NWC connections: the model half of the NIP-47 wallet service - creating
|
|
// connections, deriving their keys, and the connection-string codec. No
|
|
// relay I/O here; the runtime lives in service.ts.
|
|
//
|
|
// Key material, per connection:
|
|
// - the CLIENT keypair: a fresh random secret, handed to the client app
|
|
// exactly once inside the connection string. The wallet stores only its
|
|
// pubkey (NIP-47: the wallet service should not store the secret it
|
|
// generates for the client) - a leaked device backup then can't
|
|
// impersonate a client, and a lost connection string simply means
|
|
// creating a new connection.
|
|
// - the WALLET-SERVICE keypair: derived, not generated:
|
|
// sha256(linking key || context || client pubkey), the same construction
|
|
// as nostr/events.ts's deriveBackupKey under a different context string.
|
|
// Deterministic derivation is what lets a restored seed serve its old
|
|
// connections again without any extra backup: the persisted record
|
|
// (client pubkey, relays, budget) re-yields the identical wallet key, so
|
|
// the client keeps talking to the same wallet-service pubkey.
|
|
//
|
|
// The connection string follows NIP-47 exactly:
|
|
// nostr+walletconnect://<wallet-service-pubkey>?relay=wss://...&secret=<client-secret-hex>
|
|
|
|
import { sha256 } from '@noble/hashes/sha2.js';
|
|
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
|
|
import { getPublicKey } from 'nostr-tools/pure';
|
|
|
|
import { linkingPubKeyHex } from '../keys';
|
|
import type { NwcBudget, NwcConnectionRecord } from '../storage/nwcConnections';
|
|
import { persistNwcConnection } from '../storage/nwcConnections';
|
|
|
|
const NWC_WALLET_KEY_CONTEXT = 'sattle-nwc-wallet-v1';
|
|
|
|
const HEX_64 = /^[0-9a-f]{64}$/i;
|
|
|
|
// Deterministic: sha256(linking key || context || client pubkey). The
|
|
// result is a secp256k1 secret key used ONLY as this connection's
|
|
// wallet-service identity - it signs and decrypts NIP-47 events for this
|
|
// one client, nothing else.
|
|
export const deriveNwcWalletKey = (linkingPrivKey: Uint8Array, clientPubkey: string): Uint8Array =>
|
|
sha256(
|
|
new Uint8Array([
|
|
...linkingPrivKey,
|
|
...utf8ToBytes(NWC_WALLET_KEY_CONTEXT),
|
|
...hexToBytes(clientPubkey),
|
|
]),
|
|
);
|
|
|
|
// the x-only nostr pubkey the client addresses its requests to
|
|
export const nwcWalletPubkey = (walletSecretKey: Uint8Array): string =>
|
|
getPublicKey(walletSecretKey);
|
|
|
|
export type NwcConnectionInfo = {
|
|
record: NwcConnectionRecord;
|
|
walletServicePubkey: string;
|
|
};
|
|
|
|
// the runtime view of a persisted record: the record plus its re-derived
|
|
// wallet-service identity
|
|
export const connectionInfoOf = (
|
|
linkingPrivKey: Uint8Array,
|
|
record: NwcConnectionRecord,
|
|
): NwcConnectionInfo => ({
|
|
record,
|
|
walletServicePubkey: nwcWalletPubkey(deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)),
|
|
});
|
|
|
|
export type CreatedConnection = NwcConnectionInfo & {
|
|
// shown to the holder exactly once - it carries the client secret,
|
|
// which the wallet deliberately does NOT store
|
|
connectionString: string;
|
|
};
|
|
|
|
export type CreateConnectionOptions = {
|
|
relays: string[];
|
|
budget: NwcBudget;
|
|
// test hook: a fixed client secret (32 bytes) instead of a random one
|
|
clientSecret?: Uint8Array;
|
|
now?: number;
|
|
};
|
|
|
|
// Creates and persists a connection. The client secret is random by
|
|
// default; the wallet-service key falls out of the derivation above.
|
|
export const createConnection = (
|
|
linkingPrivKey: Uint8Array,
|
|
options: CreateConnectionOptions,
|
|
): CreatedConnection => {
|
|
if (options.relays.length === 0) {
|
|
throw new Error('A connection needs at least one relay.');
|
|
}
|
|
const clientSecret = options.clientSecret ?? crypto.getRandomValues(new Uint8Array(32));
|
|
const clientPubkey = getPublicKey(clientSecret);
|
|
const ownerId = linkingPubKeyHex(linkingPrivKey);
|
|
const record = persistNwcConnection(ownerId, {
|
|
version: 1,
|
|
ownerId,
|
|
clientPubkey,
|
|
relays: options.relays,
|
|
budget: options.budget,
|
|
spent: { periodStart: options.now ?? Date.now(), msat: 0 },
|
|
createdAt: options.now ?? Date.now(),
|
|
});
|
|
const info = connectionInfoOf(linkingPrivKey, record);
|
|
return {
|
|
...info,
|
|
connectionString: buildConnectionString(
|
|
info.walletServicePubkey,
|
|
bytesToHex(clientSecret),
|
|
record.relays,
|
|
),
|
|
};
|
|
};
|
|
|
|
export const buildConnectionString = (
|
|
walletServicePubkey: string,
|
|
clientSecretHex: string,
|
|
relays: string[],
|
|
): string => {
|
|
const query = relays.map((relay) => `relay=${encodeURIComponent(relay)}`).join('&');
|
|
return `nostr+walletconnect://${walletServicePubkey}?${query}&secret=${clientSecretHex}`;
|
|
};
|
|
|
|
export type ParsedConnectionString = {
|
|
walletServicePubkey: string;
|
|
clientSecret: string;
|
|
relays: string[];
|
|
};
|
|
|
|
// parses a NIP-47 connection string; returns null for anything that isn't
|
|
// exactly one (a client-side counterpart of buildConnectionString, here so
|
|
// the format has a tested inverse)
|
|
export const parseConnectionString = (uri: string): ParsedConnectionString | null => {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(uri.trim());
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (url.protocol !== 'nostr+walletconnect:') return null;
|
|
// the host is the wallet-service pubkey; normalize case so a
|
|
// hand-typed uppercase string still parses (nostr pubkeys are
|
|
// conventionally lowercase hex)
|
|
const walletServicePubkey = url.host.toLowerCase();
|
|
if (!HEX_64.test(walletServicePubkey)) return null;
|
|
const secret = url.searchParams.get('secret');
|
|
if (!secret || !HEX_64.test(secret)) return null;
|
|
const relays = url.searchParams.getAll('relay').filter((relay) => /^wss?:\/\//.test(relay));
|
|
if (relays.length === 0) return null;
|
|
return { walletServicePubkey, clientSecret: secret.toLowerCase(), relays };
|
|
};
|