Skip to content

Commit fd19ff0

Browse files
feat: bunker client mode — connect to remote signer via BUNKER_URI, full round-trip tested (324 tests)
1 parent f33008b commit fd19ff0

6 files changed

Lines changed: 224 additions & 5 deletions

File tree

src/bunker-context.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* BunkerContext — an IdentityContext-compatible interface backed by a NIP-46 bunker.
3+
*
4+
* Instead of holding the secret key locally, all signing is delegated to a
5+
* remote bunker via encrypted Nostr relay messages.
6+
*
7+
* Usage:
8+
* BUNKER_URI=bunker://pk?relay=wss://...
9+
* or
10+
* BUNKER_URI=bunker://pk?relay=wss://...&secret=clienthex
11+
*/
12+
13+
import { BunkerSigner } from 'nostr-tools/nip46'
14+
import { useWebSocketImplementation, SimplePool } from 'nostr-tools/pool'
15+
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
16+
import { npubEncode } from 'nostr-tools/nip19'
17+
import WebSocket from 'ws'
18+
import type { Event as NostrEvent, EventTemplate } from 'nostr-tools'
19+
import type { PublicIdentity, SignFn } from './types.js'
20+
21+
useWebSocketImplementation(WebSocket)
22+
23+
export interface BunkerConfig {
24+
pubkey: string
25+
relay: string
26+
secret?: string // client secret key hex
27+
}
28+
29+
/** Parse a bunker:// URI */
30+
export function parseBunkerUri(uri: string): BunkerConfig {
31+
// bunker://<pubkey>?relay=<url>&secret=<hex>
32+
const url = new URL(uri)
33+
const pubkey = url.hostname || url.pathname.replace('//', '')
34+
const relay = url.searchParams.get('relay')
35+
const secret = url.searchParams.get('secret') ?? undefined
36+
if (!pubkey || !relay) {
37+
throw new Error('Invalid bunker URI: missing pubkey or relay')
38+
}
39+
return { pubkey, relay, secret }
40+
}
41+
42+
export class BunkerContext {
43+
private signer: BunkerSigner
44+
private pool: SimplePool
45+
private pubkeyHex: string | undefined
46+
private clientSk: Uint8Array
47+
48+
private constructor(signer: BunkerSigner, pool: SimplePool, clientSk: Uint8Array) {
49+
this.signer = signer
50+
this.pool = pool
51+
this.clientSk = clientSk
52+
}
53+
54+
/** Connect to a remote bunker. Blocks until the connection is established. */
55+
static async connect(uri: string, timeoutMs = 15_000): Promise<BunkerContext> {
56+
const config = parseBunkerUri(uri)
57+
const clientSk = config.secret
58+
? Buffer.from(config.secret, 'hex')
59+
: generateSecretKey()
60+
const pool = new SimplePool()
61+
62+
const signer = BunkerSigner.fromBunker(
63+
clientSk,
64+
{ pubkey: config.pubkey, relays: [config.relay], secret: null },
65+
{ pool },
66+
)
67+
68+
// Connect and verify
69+
await signer.connect()
70+
await signer.ping()
71+
72+
const ctx = new BunkerContext(signer, pool, clientSk)
73+
ctx.pubkeyHex = await signer.getPublicKey()
74+
return ctx
75+
}
76+
77+
/** The remote identity's npub */
78+
get activeNpub(): string {
79+
return npubEncode(this.pubkeyHex!)
80+
}
81+
82+
/** The remote identity's hex pubkey */
83+
get activePublicKeyHex(): string {
84+
return this.pubkeyHex!
85+
}
86+
87+
/** Sign an event via the remote bunker */
88+
getSigningFunction(): SignFn {
89+
return async (template: EventTemplate): Promise<NostrEvent> => {
90+
return this.signer.signEvent(template) as unknown as NostrEvent
91+
}
92+
}
93+
94+
/** List identities — bunker mode only has one (the remote key) */
95+
listIdentities(): PublicIdentity[] {
96+
return [{ npub: this.activeNpub, purpose: 'bunker', index: 0 }]
97+
}
98+
99+
/** NIP-44 encrypt via the remote bunker */
100+
async nip44Encrypt(recipientPubkey: string, plaintext: string): Promise<string> {
101+
return this.signer.nip44Encrypt(recipientPubkey, plaintext)
102+
}
103+
104+
/** NIP-44 decrypt via the remote bunker */
105+
async nip44Decrypt(senderPubkey: string, ciphertext: string): Promise<string> {
106+
return this.signer.nip44Decrypt(senderPubkey, ciphertext)
107+
}
108+
109+
/** Clean up */
110+
destroy(): void {
111+
this.signer.close()
112+
this.pool.destroy()
113+
this.clientSk.fill(0)
114+
}
115+
}

src/cli.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ Modes:
182182
Environment:
183183
NOSTR_SECRET_KEY nsec, hex, or BIP-39 mnemonic
184184
NOSTR_SECRET_KEY_FILE Path to secret key file
185+
BUNKER_URI / BUNKER_URI_FILE bunker:// URI (use INSTEAD of secret key)
185186
NOSTR_RELAYS Comma-separated relay URLs
186187
NWC_URI / NWC_URI_FILE Nostr Wallet Connect URI
187188
TOR_PROXY SOCKS5h proxy URL

src/config.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,19 @@ export function loadConfig(): BrayConfig {
4242
const keyEnvVar = process.env.NOSTR_SECRET_KEY
4343
let secretKey: string
4444

45+
const bunkerUri = process.env.BUNKER_URI ?? process.env.BUNKER_URI_FILE
46+
? (process.env.BUNKER_URI_FILE ? readSecretFile(process.env.BUNKER_URI_FILE) : process.env.BUNKER_URI)
47+
: undefined
48+
4549
if (keyFilePath) {
4650
secretKey = readSecretFile(keyFilePath)
4751
} else if (keyEnvVar) {
4852
secretKey = keyEnvVar
53+
} else if (bunkerUri) {
54+
// Bunker mode — no local secret needed
55+
secretKey = ''
4956
} else {
50-
throw new Error('No secret key provided: set NOSTR_SECRET_KEY or NOSTR_SECRET_KEY_FILE')
57+
throw new Error('No secret key provided: set NOSTR_SECRET_KEY, NOSTR_SECRET_KEY_FILE, or BUNKER_URI')
5158
}
5259

5360
const secretFormat = detectKeyFormat(secretKey)
@@ -82,11 +89,14 @@ export function loadConfig(): BrayConfig {
8289
delete process.env.NOSTR_SECRET_KEY_FILE
8390
delete process.env.NWC_URI
8491
delete process.env.NWC_URI_FILE
92+
delete process.env.BUNKER_URI
93+
delete process.env.BUNKER_URI_FILE
8594

8695
return {
8796
secretKey,
8897
secretFormat,
8998
relays,
99+
bunkerUri: bunkerUri ?? undefined,
90100
nwcUri,
91101
torProxy,
92102
allowClearnetWithTor,

src/index.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,22 @@ const pool = new RelayPool({
1919
defaultRelays: config.relays,
2020
})
2121
const nip65 = new Nip65Manager(pool, config.relays)
22-
const ctx = new IdentityContext(config.secretKey, config.secretFormat)
23-
export const deps = { ctx, pool, nip65, nwcUri: config.nwcUri }
2422

25-
// Clear secret references from config — strings are immutable so originals
26-
// persist until GC, but removing references allows earlier collection
23+
// Connect to bunker or use local key
24+
let ctx: IdentityContext | import('./bunker-context.js').BunkerContext
25+
if (config.bunkerUri) {
26+
const { BunkerContext } = await import('./bunker-context.js')
27+
ctx = await BunkerContext.connect(config.bunkerUri)
28+
console.error(`Connected to bunker — signing as ${ctx.activeNpub}`)
29+
} else {
30+
ctx = new IdentityContext(config.secretKey, config.secretFormat)
31+
}
32+
33+
export const deps = { ctx: ctx as any, pool, nip65, nwcUri: config.nwcUri }
34+
2735
;(config as any).secretKey = ''
2836
;(config as any).nwcUri = undefined
37+
;(config as any).bunkerUri = undefined
2938

3039
// Load master identity relay list
3140
const masterRelays = await nip65.loadForIdentity(ctx.activeNpub)

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export interface BrayConfig {
3737
readonly secretKey: string
3838
readonly secretFormat: 'nsec' | 'hex' | 'mnemonic'
3939
readonly relays: string[]
40+
readonly bunkerUri?: string
4041
readonly nwcUri?: string
4142
readonly torProxy?: string
4243
readonly allowClearnetWithTor: boolean

test/bunker-round-trip.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* Bunker round-trip test — server + client via our test relay.
3+
*
4+
* 1. Start in-memory relay
5+
* 2. Start bunker server (holds the key)
6+
* 3. Connect bunker client
7+
* 4. Sign an event via the bunker
8+
* 5. Verify the signature
9+
*/
10+
11+
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
12+
import { verifyEvent } from 'nostr-tools/pure'
13+
import { startRelay } from '../src/serve.js'
14+
import { startBunker } from '../src/bunker.js'
15+
import { IdentityContext } from '../src/context.js'
16+
import { BunkerContext } from '../src/bunker-context.js'
17+
18+
const TEST_NSEC = 'nsec1cxymst7yntfnvt4vkztk54q9muks6n77dn7qyhjpcvlxtkc6hy2s0364r8'
19+
20+
let relay: ReturnType<typeof startRelay>
21+
let bunkerServer: ReturnType<typeof startBunker>
22+
let ctx: IdentityContext
23+
24+
describe('bunker round-trip', () => {
25+
beforeAll(() => {
26+
// Start test relay
27+
relay = startRelay({ port: 19648, quiet: true })
28+
29+
// Start bunker server with local context
30+
ctx = new IdentityContext(TEST_NSEC, 'nsec')
31+
bunkerServer = startBunker({
32+
ctx,
33+
relays: [relay.url],
34+
quiet: true,
35+
})
36+
})
37+
38+
afterAll(() => {
39+
bunkerServer.close()
40+
ctx.destroy()
41+
relay.close()
42+
})
43+
44+
it('bunker server starts and returns a URI', () => {
45+
expect(bunkerServer.url).toMatch(/^bunker:\/\//)
46+
expect(bunkerServer.pubkey).toMatch(/^[0-9a-f]{64}$/)
47+
expect(bunkerServer.npub).toMatch(/^npub1/)
48+
})
49+
50+
it('client connects to bunker and gets public key', async () => {
51+
const client = await BunkerContext.connect(bunkerServer.url)
52+
expect(client.activeNpub).toBe(ctx.activeNpub)
53+
expect(client.activePublicKeyHex).toBe(ctx.activePublicKeyHex)
54+
client.destroy()
55+
}, 15_000)
56+
57+
it('client signs an event via the bunker', async () => {
58+
const client = await BunkerContext.connect(bunkerServer.url)
59+
const sign = client.getSigningFunction()
60+
61+
const event = await sign({
62+
kind: 1,
63+
created_at: Math.floor(Date.now() / 1000),
64+
tags: [],
65+
content: 'signed via bunker!',
66+
})
67+
68+
expect(event.kind).toBe(1)
69+
expect(event.content).toBe('signed via bunker!')
70+
expect(event.pubkey).toBe(ctx.activePublicKeyHex)
71+
expect(verifyEvent(event)).toBe(true)
72+
73+
client.destroy()
74+
}, 15_000)
75+
76+
it('client lists identities', async () => {
77+
const client = await BunkerContext.connect(bunkerServer.url)
78+
const list = client.listIdentities()
79+
expect(list.length).toBe(1)
80+
expect(list[0].purpose).toBe('bunker')
81+
client.destroy()
82+
}, 15_000)
83+
})

0 commit comments

Comments
 (0)