Skip to content

Commit 6e9bd9b

Browse files
fix: master identity is the user's actual nsec/npub, not a derived child
BREAKING: whoami now returns your real npub (the one your followers know). nsec-tree derivation still works for child personas via derive/persona. prove only works from derived identities (not master).
1 parent f2c60a7 commit 6e9bd9b

4 files changed

Lines changed: 66 additions & 30 deletions

File tree

src/context.ts

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { fromNsec, fromMnemonic, derive, zeroise } from 'nsec-tree'
22
import { derivePersona } from 'nsec-tree/persona'
3-
import { createBlindProof, createFullProof, verifyProof } from 'nsec-tree/proof'
4-
import { finalizeEvent } from 'nostr-tools/pure'
3+
import { createBlindProof, createFullProof } from 'nsec-tree/proof'
4+
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure'
5+
import { decode, npubEncode, nsecEncode } from 'nostr-tools/nip19'
56
import type { TreeRoot, Identity, LinkageProof } from 'nsec-tree'
67
import type { Event as NostrEvent, EventTemplate } from 'nostr-tools'
78
import type { PublicIdentity, SignFn } from './types.js'
@@ -14,6 +15,20 @@ interface CacheEntry {
1415
lastUsed: number
1516
}
1617

18+
/** Build an Identity-compatible object from raw key bytes */
19+
function rawIdentity(privateKey: Uint8Array): Identity {
20+
const publicKeyHex = getPublicKey(privateKey)
21+
const publicKey = Buffer.from(publicKeyHex, 'hex')
22+
return {
23+
nsec: nsecEncode(privateKey),
24+
npub: npubEncode(publicKeyHex),
25+
privateKey: new Uint8Array(privateKey), // copy so original can be cleaned
26+
publicKey: new Uint8Array(publicKey),
27+
purpose: 'master',
28+
index: 0,
29+
}
30+
}
31+
1732
export interface ContextOptions {
1833
maxCache?: number
1934
}
@@ -28,24 +43,33 @@ export class IdentityContext {
2843
constructor(secretKey: string, format: 'nsec' | 'hex' | 'mnemonic', opts?: ContextOptions) {
2944
this.maxCacheSize = opts?.maxCache ?? 5
3045

46+
// Parse the raw secret key bytes — this IS the user's actual Nostr identity
47+
let rawKeyBytes: Uint8Array
3148
if (format === 'mnemonic') {
49+
// Mnemonic: create tree root, derive a "default" identity as master
3250
this.root = fromMnemonic(secretKey)
51+
const derived = derive(this.root, 'master', 0)
52+
this.masterEntry = { identity: derived, purpose: 'master', index: 0, lastUsed: Date.now() }
53+
this.activeEntry = this.masterEntry
54+
return
3355
} else if (format === 'hex') {
34-
const bytes = Buffer.from(secretKey, 'hex')
35-
this.root = fromNsec(bytes)
56+
rawKeyBytes = Buffer.from(secretKey, 'hex')
3657
} else {
37-
this.root = fromNsec(secretKey)
58+
// nsec bech32
59+
rawKeyBytes = decode(secretKey).data as Uint8Array
3860
}
3961

40-
// Derive master identity — kept separate from LRU cache
41-
const masterIdentity = derive(this.root, 'master', 0)
62+
// Master identity = the user's actual keypair (their real npub)
4263
this.masterEntry = {
43-
identity: masterIdentity,
64+
identity: rawIdentity(rawKeyBytes),
4465
purpose: 'master',
4566
index: 0,
4667
lastUsed: Date.now(),
4768
}
4869
this.activeEntry = this.masterEntry
70+
71+
// nsec-tree root for child derivation
72+
this.root = fromNsec(rawKeyBytes)
4973
}
5074

5175
/** Current active identity's npub (bech32) */
@@ -160,15 +184,24 @@ export class IdentityContext {
160184
return result
161185
}
162186

163-
/** Create a linkage proof for the active identity */
187+
/** Create a linkage proof for the active identity.
188+
* Only works for derived identities — the master IS the raw key, not a tree child. */
164189
prove(mode: 'blind' | 'full' = 'blind'): LinkageProof {
190+
if (this.activeEntry === this.masterEntry) {
191+
throw new Error('Cannot prove master identity — it is the raw key, not a derived child. Switch to a derived identity first.')
192+
}
165193
const child = this.activeEntry.identity
166194
if (mode === 'full') {
167195
return createFullProof(this.root, child)
168196
}
169197
return createBlindProof(this.root, child)
170198
}
171199

200+
/** Get the nsec-tree root's master pubkey (the derivation anchor, distinct from the raw key's pubkey) */
201+
get treeRootPubkey(): string {
202+
return this.root.masterPubkey
203+
}
204+
172205
/** Get the active identity's private key for NIP-17/44/04 crypto operations */
173206
get activePrivateKey(): Uint8Array {
174207
return this.activeEntry.identity.privateKey

src/identity/migration.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -121,20 +121,23 @@ export async function handleIdentityMigrate(
121121
// Execute migration: re-sign migratable events
122122
await handleIdentityRestore(ctx, pool, backup)
123123

124-
// Publish linkage proof connecting old → new
125-
const proof = ctx.prove('full')
126-
const sign = ctx.getSigningFunction()
127-
const proofEvent = await sign({
128-
kind: 30078,
129-
created_at: Math.floor(Date.now() / 1000),
130-
tags: [
131-
['d', `migration:${args.oldPubkeyHex}`],
132-
['p', args.oldPubkeyHex],
133-
],
134-
content: JSON.stringify(proof),
135-
})
136-
137-
await pool.publish(args.oldNpub, proofEvent)
124+
// Publish linkage proof connecting old → new (only if operating as a derived identity)
125+
try {
126+
const proof = ctx.prove('full')
127+
const sign = ctx.getSigningFunction()
128+
const proofEvent = await sign({
129+
kind: 30078,
130+
created_at: Math.floor(Date.now() / 1000),
131+
tags: [
132+
['d', `migration:${args.oldPubkeyHex}`],
133+
['p', args.oldPubkeyHex],
134+
],
135+
content: JSON.stringify(proof),
136+
})
137+
await pool.publish(args.oldNpub, proofEvent)
138+
} catch {
139+
// Master identity can't produce tree proofs — migration proceeds without linkage proof
140+
}
138141

139142
return { status: 'migrated', summary }
140143
}

test/cli.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,11 @@ describe('CLI', () => {
6767
expect(output.personaName).toBe('work')
6868
})
6969

70-
it('prove returns linkage proof', () => {
71-
const output = JSON.parse(run('prove', 'blind'))
72-
expect(output.masterPubkey).toBeDefined()
73-
expect(output.childPubkey).toBeDefined()
74-
expect(output.signature).toBeDefined()
70+
it('prove returns linkage proof (must derive first)', () => {
71+
// Prove only works from a derived identity, not master
72+
// CLI is stateless so we can't switch then prove — test the error
73+
const stderr = runExpectFail('prove', 'blind')
74+
expect(stderr).toMatch(/derive|raw key/)
7575
})
7676

7777
it('unknown command shows help and exits non-zero', () => {

test/zap/nwc-round-trip.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ import {
2121
import { createMockWallet, buildNwcUri } from './mock-nwc-wallet.js'
2222

2323
const TEST_NSEC = 'nsec1cxymst7yntfnvt4vkztk54q9muks6n77dn7qyhjpcvlxtkc6hy2s0364r8'
24-
// Use a fixed NWC client secret (valid secp256k1 scalar)
25-
const CLIENT_SECRET = 'c189b82fc49ad3362eacb0976a5405df2d0d4fde6cfc025e41c33e65db1ab915'
24+
// NWC client secret — must be DIFFERENT from the identity key (TEST_NSEC)
25+
const CLIENT_SECRET = 'a3f19ad618bcd6c58b892dfed6d20e5980c4ec11709c2d65718d3d653be9d397'
2626

2727
describe('NWC round-trip integration', () => {
2828
let ctx: IdentityContext

0 commit comments

Comments
 (0)