Files
eranos/src/lib/nostrPush.ts
T
Alex Gleason f3393b2cc8 Store nostr-push device key in secure storage on native builds
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.
2026-04-16 13:47:16 -05:00

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;
}
}