Key Wrapping to solve NIP-44 latency in NIP-46 clients (NIP-60 extension)
Tags / Keywords: NIP-60, NIP-44, NIP-46, Encryption, Performance, Cashu, Key Wrapping
Abstract This proposal addresses a critical performance bottleneck when using NIP-44 encryption in combination with NIP-46 remote signers (e.g., Amber, Bunker, Keyston). By implementing a "Key Wrapping" architecture, we can reduce the number of remote signer calls from $O(n)$ to $O(1)$ during wallet initialization, reducing load times from 30s+ to sub-second speeds.
The Problem Current specifications (like NIP-60 for Cashu) often require decrypting multiple events (e.g., Token Events) individually using the user's Identity Key. For clients using NIP-46 (Remote Signing):
- Fetching 50 token events requires 50 separate WebSocket calls to the signer to decrypt the NIP-44 payloads.
- This introduces massive latency and potential points of failure (timeouts).
- It forces the user to approve decryption repeatedly or rely on "auto-approve" policies which might be too broad.
The Proposed Solution: Ephemeral Data Keys Instead of encrypting data directly to the Identity Key (NIP-44), clients should:
- Generate a local, random Data Key (Wallet Key).
- Encrypt this Data Key once using the Identity Key (NIP-44) and store it in a configuration event (e.g., NIP-60 Kind 17375).
- Encrypt all payload data (Tokens, History) using this Data Key locally.
Benefits
- Performance: Only one NIP-46 call is needed at startup (to unwrap the Data Key). All subsequent NIP-44 operations happen locally in memory (0ms latency).
- UX: The user only sees one "Decrypt Config" request instead of spamming requests.
- Security: The Identity Key is exposed less frequently. The Data Key can be rotated easily without changing the user's identity.
Reference Implementation (Logic)
Here is a simplified TypeScript implementation demonstrating the flow used in our Progressive Web App (PWA):
// 1. Initialization (The only NIP-46 Call)
async function initializeWallet(signer: RemoteSigner) {
// Fetch the "Wrapper" Config (Kind 17375)
const configEvent = await fetchConfigEvent();
let walletKey: string;
if (configEvent) {
// UNWRAP: Use Remote Signer (NIP-46) - ONE TIME ONLY
const decrypted = await signer.nip44Decrypt(configEvent.content);
walletKey = JSON.parse(decrypted).privkey;
} else {
// Generate new local key if none exists
walletKey = generateRandomKey();
// Wrap it and save to relays
const wrapped = await signer.nip44Encrypt(JSON.stringify({ privkey: walletKey }));
await publishConfig(wrapped);
}
// Initialize a local signer with the unwrapped key
return new LocalSigner(walletKey);
}
// 2. Usage (Zero Latency)
async function loadTokens(localSigner: LocalSigner) {
const events = await fetchTokenEvents();
// DECRYPT: Happens locally in memory using the Data Key
// No network calls to Amber/Bunker needed here!
const tokens = await Promise.all(events.map(evt =>
localSigner.nip44Decrypt(evt.content)
));
return tokens;
}
I think the long term solution is: https://github.com/nostr-protocol/nips/pull/1647
Since it's always the same key, NIP-46 could also just return the get_conversation_key once from NIP-44 and let the client decrypt everything based on that shared key.
Yes, you're right, your approach is much simpler. Does NIP-46/amber support get_conversation_key?
Why was get_conversation_key removed from the NIP-46 spec?
Why was get_conversation_key removed from the NIP-46 spec?
People were chickens
I'm still a chicken, but now I'm building something that returns conversation keys to the client (because you can't do encryption with frost), so consider my objections invalidated now.