Skip to content

Commit ae8a1f3

Browse files
committed
fix(nip98): fresh-token nonce, page-side body hash, redirect-aware retry; drop dead code; gate secret logs
NIP-98 hardening: - Add a random 'nonce' tag to every kind-27235 event. created_at has 1s resolution, so two identical requests in the same second previously produced a byte-identical event id (a replayable token even after the cache removal). - Compute the 'payload' body hash in the page context (nip98-interceptor.js), where FormData / URLSearchParams / Blob / typed-array bodies are still intact, and send only the hex digest. The raw body no longer crosses the message channel (it could not be structured-cloned faithfully), which also fixes the missing payload hash for URLSearchParams/binary bodies. background.js consumes the precomputed hash, falling back to hashing string/ArrayBuffer/Blob locally. - On the 401 retry, re-sign against response.url when the original request was redirected, so the 'u' tag matches the endpoint that issued the challenge. Dead code: - Remove unused retryState map and formatEventForPrompt (the approval popup inlines its own preview). - Remove the NIP04_ENCRYPT/DECRYPT and GET_RELAYS message cases now that the provider no longer advertises them. Secret-log hygiene: - Introduce a DEBUG flag (default off) in background.js, popup.js and nip98-interceptor.js and gate every console statement that prints a public key, DID, signed event, Authorization header, or message result behind it. Error logs and benign lifecycle logs are kept. Co-Authored-By: jjohare <github@thedreamlab.uk>
1 parent 19b545d commit ae8a1f3

4 files changed

Lines changed: 104 additions & 110 deletions

File tree

popup/popup.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
* Podkey - Popup UI Logic
33
*/
44

5+
// Set DEBUG=true to log identity material (public key / DID) for local
6+
// debugging. Off by default so the popup never prints the user's pubkey.
7+
const DEBUG = false;
8+
59
// UI State
610
let currentScreen = 'setup';
711

@@ -20,7 +24,7 @@ async function checkKeypairStatus() {
2024
console.log('[Podkey Popup] Checking keypair status...');
2125
try {
2226
const response = await chrome.runtime.sendMessage({ type: 'GET_KEYPAIR_STATUS' });
23-
console.log('[Podkey Popup] Keypair status response:', response);
27+
if (DEBUG) console.log('[Podkey Popup] Keypair status response:', response);
2428

2529
if (response.exists) {
2630
console.log('[Podkey Popup] Keypair exists, showing main screen');
@@ -116,7 +120,7 @@ async function handleGenerate() {
116120

117121
const response = await chrome.runtime.sendMessage({ type: 'GENERATE_KEYPAIR' });
118122

119-
console.log('[Podkey] Keypair generated:', response.publicKey);
123+
if (DEBUG) console.log('[Podkey] Keypair generated:', response.publicKey);
120124

121125
// Show main screen
122126
await showMainScreen({
@@ -152,7 +156,7 @@ async function handleImport() {
152156
privateKey
153157
});
154158

155-
console.log('[Podkey] Keypair imported:', response.publicKey);
159+
if (DEBUG) console.log('[Podkey] Keypair imported:', response.publicKey);
156160

157161
// Clear input
158162
document.getElementById('privateKeyInput').value = '';

src/background.js

Lines changed: 41 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,11 @@ import {
2121
import { sha256 } from '@noble/hashes/sha256';
2222
import { bytesToHex } from '@noble/hashes/utils';
2323

24-
console.log('[Podkey] Background service worker started');
24+
// Set DEBUG=true to emit verbose diagnostics. Off by default so the extension
25+
// never logs public keys, DIDs, NIP-98 events, or Authorization headers.
26+
const DEBUG = false;
2527

26-
// NIP-98 events are always created fresh to avoid replay issues
27-
28-
// Track retry state to prevent infinite loops: key = requestId, value = true
29-
const retryState = new Map();
28+
if (DEBUG) console.log('[Podkey] Background service worker started');
3029

3130
// Initialize extension
3231
chrome.runtime.onInstalled.addListener(async () => {
@@ -57,7 +56,9 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
5756

5857
handleMessage(message, sender)
5958
.then(result => {
60-
console.log('[Podkey] Message handled successfully:', message.type, result);
59+
// Never log `result`: it may be a public key, signed event, or
60+
// Authorization header (token). Log only the message type under DEBUG.
61+
if (DEBUG) console.log('[Podkey] Message handled:', message.type);
6162
sendResponse(result);
6263
})
6364
.catch(error => {
@@ -74,7 +75,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
7475
async function handleMessage (message, sender) {
7576
const { type, origin } = message;
7677

77-
console.log('[Podkey] Message received:', type, 'from', origin || 'popup');
78+
if (DEBUG) console.log('[Podkey] Message received:', type, 'from', origin || 'popup');
7879

7980
switch (type) {
8081
case 'GET_PUBLIC_KEY':
@@ -92,14 +93,6 @@ async function handleMessage (message, sender) {
9293
case 'GET_KEYPAIR_STATUS':
9394
return await handleGetKeypairStatus();
9495

95-
case 'GET_RELAYS':
96-
// TODO: Implement relay management
97-
return {};
98-
99-
case 'NIP04_ENCRYPT':
100-
case 'NIP04_DECRYPT':
101-
throw new Error('NIP-04 encryption not yet implemented');
102-
10396
case 'NIP44_ENCRYPT':
10497
return await handleNip44Encrypt(message.pubkey, message.plaintext, origin);
10598

@@ -113,7 +106,8 @@ async function handleMessage (message, sender) {
113106
return await createNip98AuthHeader(
114107
message.url,
115108
message.method,
116-
message.body
109+
message.body,
110+
message.bodyHash
117111
);
118112

119113
default:
@@ -196,7 +190,7 @@ async function handleSignEvent (event, origin, sender) {
196190
// Sign the event
197191
const signedEvent = await signEvent(event, keypair.privateKey);
198192

199-
console.log('[Podkey] Event signed:', signedEvent.id.substring(0, 16) + '...');
193+
if (DEBUG) console.log('[Podkey] Event signed:', signedEvent.id.substring(0, 16) + '...');
200194

201195
// Ensure event structure is correct (tags should be array, content should be string)
202196
// Preserve original event structure but ensure required fields are correct types
@@ -298,23 +292,14 @@ async function handleNip44GetConversationKey (peerPubkey, origin) {
298292
*/
299293
async function handleGenerateKeypair () {
300294
try {
301-
console.log('[Podkey] Starting keypair generation...');
302295
const keypair = await generateKeypair();
303-
console.log('[Podkey] Keypair generated:', {
304-
privateKeyLength: keypair.privateKey.length,
305-
publicKeyLength: keypair.publicKey.length
306-
});
307-
308296
await storeKeypair(keypair.privateKey, keypair.publicKey);
309-
console.log('[Podkey] Keypair stored');
297+
if (DEBUG) console.log('[Podkey] Keypair generated and stored');
310298

311-
const result = {
299+
return {
312300
publicKey: keypair.publicKey,
313301
did: `did:nostr:${keypair.publicKey}`
314302
};
315-
316-
console.log('[Podkey] Returning result:', result);
317-
return result;
318303
} catch (error) {
319304
console.error('[Podkey] Error generating keypair:', error);
320305
throw error;
@@ -340,7 +325,7 @@ async function handleImportKeypair (privateKey) {
340325
// Store keypair
341326
await storeKeypair(privateKey, publicKey);
342327

343-
console.log('[Podkey] Keypair imported');
328+
if (DEBUG) console.log('[Podkey] Keypair imported');
344329

345330
return {
346331
publicKey,
@@ -415,29 +400,6 @@ async function showPermissionPrompt (origin, action, eventPreview) {
415400
});
416401
}
417402

418-
/**
419-
* Format event for display in prompt
420-
*/
421-
function formatEventForPrompt (event) {
422-
const lines = [];
423-
lines.push(`Kind: ${event.kind}`);
424-
425-
if (event.tags && event.tags.length > 0) {
426-
lines.push(`Tags: ${event.tags.length}`);
427-
event.tags.slice(0, 3).forEach(tag => {
428-
lines.push(` [${tag.join(', ')}]`);
429-
});
430-
}
431-
432-
if (event.content) {
433-
const preview = event.content.substring(0, 100);
434-
lines.push(`Content: ${preview}${event.content.length > 100 ? '...' : ''}`);
435-
}
436-
437-
return lines.join('\n');
438-
}
439-
440-
441403
/**
442404
* Encode signed event to Authorization header value
443405
* @param {object} signedEvent - Signed Nostr event
@@ -482,7 +444,7 @@ function isLikelySolidServer (origin) {
482444
);
483445
}
484446

485-
async function createNip98AuthHeader (url, method, body = null) {
447+
async function createNip98AuthHeader (url, method, body = null, bodyHash = null) {
486448
try {
487449
// Check if we should add auth
488450
const origin = new URL(url).origin;
@@ -491,73 +453,69 @@ async function createNip98AuthHeader (url, method, body = null) {
491453
const keyExists = await hasKeypair();
492454
const isSolid = isLikelySolidServer(origin);
493455

494-
console.log('[Podkey] NIP-98 auth check:', {
495-
url,
496-
origin,
497-
keyExists,
498-
trusted,
499-
autoSign,
500-
isSolid
501-
});
456+
if (DEBUG) console.log('[Podkey] NIP-98 auth check:', { url, origin, keyExists, trusted, autoSign, isSolid });
502457

503458
if (!keyExists) {
504-
console.log('[Podkey] No keypair found, skipping NIP-98 auth');
459+
if (DEBUG) console.log('[Podkey] No keypair found, skipping NIP-98 auth');
505460
return null;
506461
}
507462

508463
// For Solid servers, auto-trust on first use if auto-sign is enabled
509464
if (!trusted && isSolid && autoSign) {
510-
console.log('[Podkey] Auto-trusting Solid server:', origin);
465+
if (DEBUG) console.log('[Podkey] Auto-trusting Solid server:', origin);
511466
await addTrustedOrigin(origin);
512467
} else if (!trusted) {
513-
console.log('[Podkey] Origin not trusted, skipping NIP-98 auth');
468+
if (DEBUG) console.log('[Podkey] Origin not trusted, skipping NIP-98 auth');
514469
return null;
515470
}
516471

517472
if (!autoSign) {
518-
console.log('[Podkey] Auto-sign disabled, skipping NIP-98 auth');
473+
if (DEBUG) console.log('[Podkey] Auto-sign disabled, skipping NIP-98 auth');
519474
return null;
520475
}
521476

522-
// Hash body if present
523-
let bodyHash = '';
524-
if (body) {
477+
// Prefer a body hash computed in the page context (the only place where
478+
// FormData / URLSearchParams / streamed bodies survive intact). Fall back
479+
// to hashing here for body types that cross the message channel losslessly.
480+
let resolvedBodyHash = typeof bodyHash === 'string' ? bodyHash : '';
481+
if (!resolvedBodyHash && body) {
525482
if (typeof body === 'string') {
526-
bodyHash = bytesToHex(sha256(new TextEncoder().encode(body)));
483+
resolvedBodyHash = bytesToHex(sha256(new TextEncoder().encode(body)));
527484
} else if (body instanceof ArrayBuffer) {
528-
bodyHash = bytesToHex(sha256(new Uint8Array(body)));
485+
resolvedBodyHash = bytesToHex(sha256(new Uint8Array(body)));
529486
} else if (body instanceof Blob) {
530-
const arrayBuffer = await body.arrayBuffer();
531-
bodyHash = bytesToHex(sha256(new Uint8Array(arrayBuffer)));
487+
resolvedBodyHash = bytesToHex(sha256(new Uint8Array(await body.arrayBuffer())));
532488
}
533489
}
534490

535491
// Always create a fresh signed event (no caching -- reusing signed events
536-
// causes replay issues and servers with replay protection reject duplicates)
492+
// causes replay issues and servers with replay protection reject them).
493+
// created_at has 1-second resolution, so a random nonce tag guarantees a
494+
// distinct event id for repeated identical requests within the same second.
537495
const event = {
538496
kind: 27235,
539497
content: '',
540498
created_at: Math.floor(Date.now() / 1000),
541499
tags: [
542500
['u', url],
543-
['method', method]
501+
['method', method],
502+
['nonce', bytesToHex(crypto.getRandomValues(new Uint8Array(16)))]
544503
]
545504
};
546505

547-
if (bodyHash) {
548-
event.tags.push(['payload', bodyHash]);
506+
if (resolvedBodyHash) {
507+
event.tags.push(['payload', resolvedBodyHash]);
549508
}
550509

551510
const keypair = await getKeypair();
552511
const signedEvent = await signEvent(event, keypair.privateKey);
553512

554-
console.log('[Podkey] Created and signed NIP-98 auth event for', url);
555-
console.log('[Podkey] NIP-98 event:', JSON.stringify(signedEvent, null, 2));
556-
console.log('[Podkey] Public key (did:nostr):', `did:nostr:${keypair.publicKey}`);
513+
if (DEBUG) {
514+
console.log('[Podkey] NIP-98 event:', JSON.stringify(signedEvent));
515+
console.log('[Podkey] Public key (did:nostr):', `did:nostr:${keypair.publicKey}`);
516+
}
557517

558-
const authHeader = `Nostr ${encodeNip98Header(signedEvent)}`;
559-
console.log('[Podkey] Authorization header (first 100 chars):', authHeader.substring(0, 100) + '...');
560-
return authHeader;
518+
return `Nostr ${encodeNip98Header(signedEvent)}`;
561519
} catch (error) {
562520
console.error('[Podkey] Error creating NIP-98 auth header:', error);
563521
return null;
@@ -569,4 +527,4 @@ async function createNip98AuthHeader (url, method, body = null) {
569527
// Instead, we use JavaScript-level interception via content scripts.
570528
// See src/injected.js for fetch/XMLHttpRequest interception.
571529

572-
console.log('[Podkey] NIP-98 auto-auth: Using JavaScript-level interception (see injected.js)');
530+
if (DEBUG) console.log('[Podkey] NIP-98 auto-auth: Using JavaScript-level interception (see injected.js)');

src/injected.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,19 @@ interceptorScript.onerror = function () {
1919
};
2020
(document.head || document.documentElement).appendChild(interceptorScript);
2121

22-
// Listen for NIP-98 auth requests from page context
22+
// Listen for NIP-98 auth requests from page context. The request body never
23+
// crosses this boundary -- the page context computes its SHA-256 (the only
24+
// place FormData / URLSearchParams / streamed bodies survive intact) and sends
25+
// only the hex digest for the NIP-98 `payload` tag.
2326
window.addEventListener('podkey-nip98-request', async (event) => {
24-
const { id, url, method, body } = event.detail;
27+
const { id, url, method, bodyHash } = event.detail;
2528

2629
try {
2730
const response = await chrome.runtime.sendMessage({
2831
type: 'CREATE_NIP98_AUTH_HEADER',
2932
url,
3033
method,
31-
body
34+
bodyHash
3235
});
3336

3437
if (chrome.runtime.lastError) {

0 commit comments

Comments
 (0)