Skip to content

Commit 54b30ae

Browse files
committed
feat: cancellable NIP-46 requests, hide permissions for remote signer accounts
- Add abort infrastructure to NIP-46 in-flight requests so users can cancel pending remote signer operations from the popup - Cancel button on NIP-46 approval cards (× icon) and detail modal - Wire signer_cancelNip46 RPC method in background - Add isNip46 to AccountContext for account-type-aware UI - Hide "Manage permissions" for NIP-46 accounts, show "Managed by your signer app" - PermissionsSection shows managed-by-signer banner and filters to getPublicKey only - Add approval.cancelNip46 locale string (6 languages) - Add cancelNip46InFlight tests - Bump version to 0.2.2
1 parent 5fff0e6 commit 54b30ae

26 files changed

Lines changed: 597 additions & 243 deletions

background.ts

Lines changed: 71 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import { bytesToHex } from './lib/crypto/utils.ts';
1515
import { ncryptsecEncode, ncryptsecDecode } from './lib/crypto/nip49.ts';
1616
import { signEvent } from './lib/crypto/nip01.ts';
1717
import { getDefaultsForDomain } from './src/shared/adapterDefaults.ts';
18-
import { Nip46Client } from './lib/nip46.ts';
18+
import { BunkerSigner, createNostrConnectURI } from 'nostr-tools/nip46';
19+
import { generateSecretKey, getPublicKey as ntGetPublicKey } from 'nostr-tools/pure';
1920
import type { Account, SignedEvent, ScoringConfig, UnsignedEvent, VaultPayload } from './lib/types.ts';
2021

2122
// Sanitize user-provided CSS to prevent data exfiltration via url(), @import, etc.
@@ -32,13 +33,13 @@ const DEFAULT_RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://nostr-01
3233

3334
// In-memory sessions for pending nostrconnect:// QR flows
3435
interface NostrConnectSession {
35-
client: Nip46Client;
36-
relay: string;
37-
localPrivkey: string;
36+
signerPromise: Promise<BunkerSigner>;
37+
signer: BunkerSigner | null;
38+
secretKey: Uint8Array;
3839
localPubkey: string;
39-
signerPubkey: string | null;
40-
connected: boolean;
41-
expired: boolean;
40+
relays: string[];
41+
error: Error | null;
42+
abortController: AbortController;
4243
}
4344
const _nostrConnectSessions = new Map<string, NostrConnectSession>();
4445

@@ -67,7 +68,7 @@ const PRIVILEGED_METHODS = new Set([
6768
'signer_clearPermissions', 'signer_savePermission',
6869
'signer_getPermissionsRaw', 'signer_getPermissionsForDomainRaw',
6970
'signer_copyPermissions', 'signer_getUseGlobalDefaults', 'signer_setUseGlobalDefaults',
70-
'signer_getPending', 'signer_resolve', 'signer_resolveBatch',
71+
'signer_getPending', 'signer_resolve', 'signer_resolveBatch', 'signer_cancelNip46',
7172
'onboarding_validateNsec', 'onboarding_validateNcryptsec', 'onboarding_validateMnemonic', 'onboarding_validateNpub', 'onboarding_connectNip46',
7273
'onboarding_generateAccount', 'onboarding_checkExistingSeed', 'onboarding_generateSubAccount',
7374
'onboarding_exportNcryptsec', 'onboarding_saveReadOnly', 'onboarding_createVault', 'onboarding_addToVault',
@@ -1389,6 +1390,10 @@ async function handleRequest({ method, params }: { method: string; params: Recor
13891390
await signer.resolveBatch(params.origin as string, params.method as string, params.decision as unknown as import('./lib/types.ts').RequestDecision, params.eventKind as number | undefined);
13901391
return { ok: true };
13911392

1393+
case 'signer_cancelNip46':
1394+
await signer.cancelNip46InFlight(params.id as string);
1395+
return { ok: true };
1396+
13921397
// === Onboarding methods ===
13931398

13941399
case 'onboarding_validateNsec': {
@@ -1493,50 +1498,80 @@ async function handleRequest({ method, params }: { method: string; params: Recor
14931498

14941499
case 'onboarding_connectNip46': {
14951500
const acct = accounts.connectNip46(params.bunkerUrl as string);
1496-
return { account: acct };
1501+
await setPendingOnboardingAccount(acct);
1502+
const { nip46Config: _n46, privkey: _pk, mnemonic: _mn, ...safeNip46 } = acct;
1503+
return { account: safeNip46 };
14971504
}
14981505

14991506
case 'onboarding_initNostrConnect': {
1500-
const relay = DEFAULT_RELAYS[0];
1507+
// Clean up any existing sessions before creating a new one
1508+
for (const [oldId, oldSession] of _nostrConnectSessions) {
1509+
oldSession.abortController.abort();
1510+
if (oldSession.signer) oldSession.signer.close().catch(() => {});
1511+
_nostrConnectSessions.delete(oldId);
1512+
}
1513+
1514+
// Known NIP-46 signer relays (nsec.app, etc.) + general relays
1515+
const NIP46_RELAYS = ['wss://relay.nsec.app', ...DEFAULT_RELAYS];
15011516
const connectSecret = Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join('');
1502-
const client = new Nip46Client({ pubkey: null, relay, secret: null, connectSecret });
1503-
await client.init();
1504-
const { privkey: localPrivkey, pubkey: localPubkey } = client.getLocalKeyPair();
1505-
const nostrconnectUri = Nip46Client.buildConnectUri(localPubkey, relay, {
1506-
name: 'Nostr WoT Extension'
1507-
}) + `&secret=${connectSecret}`;
1508-
await client.connect();
1517+
const ncSecretKey = generateSecretKey();
1518+
const ncLocalPubkey = ntGetPublicKey(ncSecretKey);
1519+
1520+
const nostrconnectUri = createNostrConnectURI({
1521+
clientPubkey: ncLocalPubkey,
1522+
relays: NIP46_RELAYS,
1523+
secret: connectSecret,
1524+
name: 'Nostr WoT',
1525+
url: 'https://nostr-wot.com',
1526+
image: 'https://nostr-wot.com/icon-512.png'
1527+
});
15091528

1529+
const abortController = new AbortController();
15101530
const sessionId = Array.from(crypto.getRandomValues(new Uint8Array(8)), b => b.toString(16).padStart(2, '0')).join('');
1511-
const session: NostrConnectSession = { client, relay, localPrivkey, localPubkey, signerPubkey: null, connected: false, expired: false };
1512-
_nostrConnectSessions.set(sessionId, session);
1531+
const session: NostrConnectSession = {
1532+
signerPromise: null!, // set below
1533+
signer: null,
1534+
secretKey: ncSecretKey,
1535+
localPubkey: ncLocalPubkey,
1536+
relays: NIP46_RELAYS,
1537+
error: null,
1538+
abortController,
1539+
};
15131540

1514-
// Start listening in the background
1515-
client.listenForConnect(120000).then(signerPubkey => {
1516-
session.signerPubkey = signerPubkey;
1517-
session.connected = true;
1518-
}).catch(() => {
1519-
session.expired = true;
1520-
});
1541+
// BunkerSigner.fromURI waits for the remote signer to connect
1542+
session.signerPromise = BunkerSigner.fromURI(
1543+
ncSecretKey,
1544+
nostrconnectUri,
1545+
{ onauth(url: string) { browser.tabs.create({ url }); } },
1546+
abortController.signal
1547+
);
1548+
session.signerPromise
1549+
.then(signer => { session.signer = signer; })
1550+
.catch(err => { session.error = err; });
15211551

1552+
_nostrConnectSessions.set(sessionId, session);
15221553
return { nostrconnectUri, sessionId };
15231554
}
15241555

15251556
case 'onboarding_pollNostrConnect': {
15261557
const session = _nostrConnectSessions.get(params.sessionId as string);
15271558
if (!session) return { expired: true };
1528-
if (session.connected) {
1559+
1560+
if (session.signer) {
1561+
const signerPk = session.signer.bp.pubkey;
1562+
const primaryRelay = session.relays[0];
1563+
const localPrivkeyHex = bytesToHex(session.secretKey);
15291564
const acct = accounts.connectNostrConnect(
1530-
session.signerPubkey!, session.relay,
1531-
session.localPrivkey, session.localPubkey
1565+
signerPk, primaryRelay,
1566+
localPrivkeyHex, session.localPubkey
15321567
);
15331568
_nostrConnectSessions.delete(params.sessionId as string);
1534-
session.client.close();
1535-
return { connected: true, account: acct };
1569+
await setPendingOnboardingAccount(acct);
1570+
const { nip46Config: _n46, privkey: _pk, mnemonic: _mn, ...safeNc } = acct;
1571+
return { connected: true, account: safeNc };
15361572
}
1537-
if (session.expired) {
1573+
if (session.error) {
15381574
_nostrConnectSessions.delete(params.sessionId as string);
1539-
session.client.close();
15401575
return { expired: true };
15411576
}
15421577
return { connected: false };
@@ -1545,7 +1580,8 @@ async function handleRequest({ method, params }: { method: string; params: Recor
15451580
case 'onboarding_cancelNostrConnect': {
15461581
const session2 = _nostrConnectSessions.get(params.sessionId as string);
15471582
if (session2) {
1548-
session2.client.close();
1583+
session2.abortController.abort();
1584+
if (session2.signer) session2.signer.close().catch(() => {});
15491585
_nostrConnectSessions.delete(params.sessionId as string);
15501586
}
15511587
return { ok: true };
@@ -1842,7 +1878,7 @@ async function handleRequest({ method, params }: { method: string; params: Recor
18421878
const clientConnected = signer.isNip46Connected(nip46Acct.id);
18431879

18441880
return {
1845-
bunkerPubkey: nip46Config.bunkerUrl ? new URL('nostr://' + nip46Config.bunkerUrl.replace('bunker://', '')).pathname.slice(2, 66) : null,
1881+
bunkerPubkey: nip46Acct.pubkey,
18461882
relay: nip46Config.relay,
18471883
connected: clientConnected,
18481884
accountId: nip46Acct.id,

lib/crypto/nip44.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -91,16 +91,17 @@ export async function nip44Encrypt(plaintext: string, privkey: Uint8Array, their
9191
const padded = pad(plaintext);
9292
const ciphertext = chacha20(chachaKey, chaChaNonce, padded);
9393

94-
const payload = concatBytes(
94+
// HMAC covers nonce || ciphertext (NOT the version byte) per NIP-44 spec
95+
const hmacInput = concatBytes(nonce, ciphertext);
96+
const mac = hmac(sha256, hmacKey, hmacInput);
97+
98+
const final = concatBytes(
9599
new Uint8Array([NIP44_VERSION]),
96100
nonce,
97-
ciphertext
101+
ciphertext,
102+
mac
98103
);
99104

100-
const mac = hmac(sha256, hmacKey, payload);
101-
102-
const final = concatBytes(payload, mac);
103-
104105
return arrayToBase64(final);
105106
} finally {
106107
conversationKey.fill(0);
@@ -118,15 +119,16 @@ export async function nip44Decrypt(data: string, privkey: Uint8Array, theirPubke
118119
if (version !== NIP44_VERSION) throw new Error(`Unsupported NIP-44 version: ${version}`);
119120

120121
const nonce = raw.slice(1, 33);
121-
const mac = raw.slice(raw.length - 32);
122-
const payload = raw.slice(0, raw.length - 32);
123122
const ciphertext = raw.slice(33, raw.length - 32);
123+
const mac = raw.slice(raw.length - 32);
124124

125125
const conversationKey = getConversationKey(privkey, theirPubkey);
126126
const { chachaKey, chaChaNonce, hmacKey } = getMessageKeys(conversationKey, nonce);
127127

128128
try {
129-
const expectedMac = hmac(sha256, hmacKey, payload);
129+
// HMAC covers nonce || ciphertext (NOT the version byte) per NIP-44 spec
130+
const hmacInput = concatBytes(nonce, ciphertext);
131+
const expectedMac = hmac(sha256, hmacKey, hmacInput);
130132
if (!constantTimeEqual(mac, expectedMac)) {
131133
throw new Error('Invalid MAC');
132134
}

0 commit comments

Comments
 (0)