f3393b2cc8
The per-device ephemeral key used to sign nostr-push RPC events was previously stored unconditionally in localStorage. On Capacitor builds this bypassed the iOS Keychain / Android KeyStore wrapper that every other persistent key in the app already uses. Route the key through `secureStorage`, which keeps the native path encrypted at rest and falls back to localStorage on web (where it was before). Because the key is now loaded asynchronously, convert the `NostrPushClient` constructor into a private constructor plus a public `create()` factory, and restructure `usePushNotifications` bring-up to await the client before registering the service worker. The key is ephemeral and per-device, so compromise only reveals which Nostr events this device subscribes to -- not the user's identity -- but matching the existing secure-storage contract closes an obvious inconsistency.
261 lines
8.8 KiB
TypeScript
261 lines
8.8 KiB
TypeScript
/**
|
|
* nostr-push RPC client
|
|
*
|
|
* Sends RPC calls to the nostr-push server via encrypted Nostr events
|
|
* (kind 25742, NIP-44) and awaits a confirmation response.
|
|
*
|
|
* Uses an ephemeral keypair (generated once, persisted in localStorage)
|
|
* to avoid prompting the user's signer for every RPC call.
|
|
*
|
|
* Protocol:
|
|
* Request: kind 25742, tags [["p", serverPubkey]]
|
|
* content: nip44Encrypt(serverPubkey, JSON.stringify({ method, params, request_id }))
|
|
*
|
|
* Response: kind 25742, tags [["p", clientPubkey]], authored by serverPubkey
|
|
* content: nip44Encrypt(clientPubkey, JSON.stringify({ request_id, success, error? }))
|
|
*/
|
|
|
|
import { nip44, generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools';
|
|
import { SimplePool } from 'nostr-tools';
|
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
|
|
|
|
import { secureStorage } from '@/lib/secureStorage';
|
|
|
|
// ─── Ephemeral device key ─────────────────────────────────────────────────────
|
|
|
|
const DEVICE_KEY_STORAGE = 'ditto-push-device-key';
|
|
|
|
/**
|
|
* Get or generate a persistent ephemeral key for this device.
|
|
* Used to sign nostr-push RPC events without prompting the user's signer.
|
|
*
|
|
* Routed through \`secureStorage\` so native builds keep the key in the iOS
|
|
* Keychain / Android KeyStore. Web falls back to localStorage (the key is
|
|
* ephemeral and per-device, so a plaintext copy only leaks which Nostr
|
|
* events this device wants pushed — not the user's identity).
|
|
*/
|
|
async function getDeviceSecretKey(): Promise<Uint8Array> {
|
|
const stored = await secureStorage.getItem(DEVICE_KEY_STORAGE);
|
|
if (stored) {
|
|
return hexToBytes(stored);
|
|
}
|
|
const sk = generateSecretKey();
|
|
await secureStorage.setItem(DEVICE_KEY_STORAGE, bytesToHex(sk));
|
|
return sk;
|
|
}
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
export interface WebPushSubscription {
|
|
type: 'web';
|
|
endpoint: string;
|
|
p256dh_key: string;
|
|
auth_key: string;
|
|
}
|
|
|
|
export interface RegisterSubscriptionParams {
|
|
subscription_id: string;
|
|
domain: string;
|
|
filter: {
|
|
kinds?: number[];
|
|
authors?: string[];
|
|
'#p'?: string[];
|
|
'#t'?: string[];
|
|
since?: number;
|
|
until?: number;
|
|
};
|
|
notification: {
|
|
title: string;
|
|
body: string;
|
|
icon?: string;
|
|
badge?: string;
|
|
};
|
|
push_subscription: WebPushSubscription;
|
|
}
|
|
|
|
export interface UpdateSubscriptionParams {
|
|
subscription_id: string;
|
|
domain: string;
|
|
updates: {
|
|
is_active?: boolean;
|
|
filter?: RegisterSubscriptionParams['filter'];
|
|
notification?: RegisterSubscriptionParams['notification'];
|
|
push_subscription?: WebPushSubscription;
|
|
};
|
|
}
|
|
|
|
export interface DeleteSubscriptionParams {
|
|
subscription_id: string;
|
|
domain: string;
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
/** Convert a browser PushSubscription to the flat structure nostr-push expects. */
|
|
export function serializePushSubscription(sub: PushSubscription): WebPushSubscription {
|
|
const keys = sub.toJSON().keys;
|
|
if (!keys?.p256dh || !keys?.auth) {
|
|
throw new Error('PushSubscription is missing p256dh or auth keys');
|
|
}
|
|
return {
|
|
type: 'web',
|
|
endpoint: sub.endpoint,
|
|
p256dh_key: keys.p256dh,
|
|
auth_key: keys.auth,
|
|
};
|
|
}
|
|
|
|
/** Convert a base64url string to a Uint8Array (for applicationServerKey). */
|
|
export function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
const rawData = atob(base64);
|
|
return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
|
|
}
|
|
|
|
// ─── Client ───────────────────────────────────────────────────────────────────
|
|
|
|
/** How long to wait for a server response before giving up. */
|
|
const RESPONSE_TIMEOUT_MS = 15_000;
|
|
|
|
export class NostrPushClient {
|
|
private pool: SimplePool;
|
|
private secretKey: Uint8Array;
|
|
private publicKey: string;
|
|
|
|
private constructor(
|
|
/** The nostr-push server's pubkey (hex). */
|
|
private readonly serverPubkey: string,
|
|
/** Relays to publish RPC calls to. */
|
|
private readonly relays: string[],
|
|
secretKey: Uint8Array,
|
|
) {
|
|
this.pool = new SimplePool();
|
|
this.secretKey = secretKey;
|
|
this.publicKey = getPublicKey(secretKey);
|
|
}
|
|
|
|
/**
|
|
* Create a new client, loading (or generating) the device key from
|
|
* platform-appropriate secure storage.
|
|
*/
|
|
static async create(
|
|
serverPubkey: string,
|
|
relays: string[],
|
|
): Promise<NostrPushClient> {
|
|
const secretKey = await getDeviceSecretKey();
|
|
return new NostrPushClient(serverPubkey, relays, secretKey);
|
|
}
|
|
|
|
/**
|
|
* Get the VAPID public key for a domain.
|
|
* Must be called before pushManager.subscribe() so the browser gets
|
|
* the correct applicationServerKey for this domain.
|
|
*/
|
|
async getVapidKey(domain: string): Promise<string> {
|
|
const result = await this.send('get_vapid_key', { domain });
|
|
const vapidKey = (result as { vapid_public_key?: string })?.vapid_public_key;
|
|
if (!vapidKey) throw new Error('nostr-push: server did not return a VAPID key');
|
|
return vapidKey;
|
|
}
|
|
|
|
/** Register (or replace) a push subscription. */
|
|
async registerSubscription(params: RegisterSubscriptionParams): Promise<void> {
|
|
await this.send('register_subscription', params);
|
|
}
|
|
|
|
/** Update an existing push subscription (e.g. activate/deactivate). */
|
|
async updateSubscription(params: UpdateSubscriptionParams): Promise<void> {
|
|
await this.send('update_subscription', params);
|
|
}
|
|
|
|
/** Delete a push subscription by its ID. */
|
|
async deleteSubscription(params: DeleteSubscriptionParams): Promise<void> {
|
|
await this.send('delete_subscription', params);
|
|
}
|
|
|
|
/** Close the relay pool. */
|
|
destroy(): void {
|
|
this.pool.close(this.relays);
|
|
}
|
|
|
|
// ─── Internal ──────────────────────────────────────────────────────────────
|
|
|
|
private async send(
|
|
method: string,
|
|
params: object,
|
|
): Promise<unknown> {
|
|
const request_id = crypto.randomUUID();
|
|
|
|
// NIP-44 encrypt the payload to the server
|
|
const conversationKey = nip44.v2.utils.getConversationKey(this.secretKey, this.serverPubkey);
|
|
const encryptedContent = nip44.v2.encrypt(
|
|
JSON.stringify({ method, params, request_id }),
|
|
conversationKey,
|
|
);
|
|
|
|
const event = finalizeEvent(
|
|
{
|
|
kind: 25742,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [['p', this.serverPubkey]],
|
|
content: encryptedContent,
|
|
},
|
|
this.secretKey,
|
|
);
|
|
|
|
// Subscribe for the response BEFORE publishing so we don't miss a fast reply.
|
|
const responsePromise = new Promise<unknown>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
sub.close();
|
|
reject(new Error(`nostr-push: RPC timeout (${method})`));
|
|
}, RESPONSE_TIMEOUT_MS);
|
|
|
|
const responseConversationKey = nip44.v2.utils.getConversationKey(this.secretKey, this.serverPubkey);
|
|
|
|
const sub = this.pool.subscribeMany(
|
|
this.relays,
|
|
[{
|
|
kinds: [25742],
|
|
authors: [this.serverPubkey],
|
|
'#p': [this.publicKey],
|
|
since: Math.floor(Date.now() / 1000) - 5,
|
|
}],
|
|
{
|
|
onevent: (responseEvent) => {
|
|
try {
|
|
const decrypted = nip44.v2.decrypt(responseEvent.content, responseConversationKey);
|
|
const response = JSON.parse(decrypted) as {
|
|
request_id: string;
|
|
success: boolean;
|
|
error?: string;
|
|
result?: unknown;
|
|
};
|
|
|
|
if (response.request_id !== request_id) return;
|
|
|
|
clearTimeout(timeout);
|
|
sub.close();
|
|
|
|
if (response.success) {
|
|
resolve(response.result);
|
|
} else {
|
|
reject(new Error(response.error ?? 'RPC call failed'));
|
|
}
|
|
} catch {
|
|
// Ignore events we can't decrypt
|
|
}
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
// Publish to relays — at least one must accept
|
|
await Promise.any(this.relays.map((url) => this.pool.publish([url], event))).catch(() => {
|
|
throw new Error('nostr-push: failed to publish RPC event to any relay');
|
|
});
|
|
|
|
return responsePromise;
|
|
}
|
|
}
|