diff --git a/README.md b/README.md index 9743314..39858d4 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,14 @@ Create a `TreeRoot` from a bech32 nsec string or raw 32-byte key. An intermediat Derive a child `Identity` from a `TreeRoot`. Returns `{ nsec, npub, privateKey, publicKey, purpose, index }`. The index defaults to `0`. +### `deriveRendezvous(root, index?)` + +Derive the Vennel `rendezvous` root child for a Link or quiet-drop pairing. +It is deliberately derived from the `TreeRoot`, never an account identity or +persona. Rotate it by incrementing `index`; provision the private half only by +an authenticated private device flow, then call `zeroise()` on the temporary +identity. This helper does not create a pairing link or export a private key. + ### `deriveFromIdentity(identity, purpose, index?)` Derive a child `Identity` from any existing `Identity`, enabling arbitrary-depth diff --git a/src/core.ts b/src/core.ts index 1d1d411..20809e3 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,5 +1,6 @@ export { fromNsec } from './root-nsec.js' export { derive, zeroise } from './derive.js' +export { deriveRendezvous, RENDEZVOUS_PURPOSE } from './rendezvous.js' export { recover } from './recover.js' export type { TreeRoot, Identity } from './types.js' export { NsecTreeError, DEFAULT_SCAN_RANGE, MAX_SCAN_RANGE, MAX_INDEX } from './types.js' diff --git a/src/index.ts b/src/index.ts index c269ccd..6c7b49b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export { fromNsec } from './root-nsec.js' export { fromMnemonic } from './root-mnemonic.js' export { derive, zeroise } from './derive.js' +export { deriveRendezvous, RENDEZVOUS_PURPOSE } from './rendezvous.js' export { recover } from './recover.js' export { createBlindProof, createFullProof, verifyProof } from './proof.js' export type { TreeRoot, Identity, LinkageProof } from './types.js' diff --git a/src/rendezvous.ts b/src/rendezvous.ts new file mode 100644 index 0000000..ec2d936 --- /dev/null +++ b/src/rendezvous.ts @@ -0,0 +1,16 @@ +import { derive } from './derive.js' +import type { Identity, TreeRoot } from './types.js' + +/** The sole root-child purpose used for a person's Vennel rendezvous key. */ +export const RENDEZVOUS_PURPOSE = 'rendezvous' + +/** + * Derive one rotated rendezvous identity directly from the person's tree root. + * + * This deliberately accepts a `TreeRoot`, not a persona or ordinary identity: + * Link tags and quiet drops must not use an account identity key. The returned + * private key is caller-owned and must be zeroised after private provisioning. + */ +export function deriveRendezvous(root: TreeRoot, index = 0): Identity { + return derive(root, RENDEZVOUS_PURPOSE, index) +} diff --git a/test/exports.test.ts b/test/exports.test.ts index cff5eb5..3fe91ee 100644 --- a/test/exports.test.ts +++ b/test/exports.test.ts @@ -6,6 +6,8 @@ describe('subpath exports', () => { expect(mod.fromNsec).toBeDefined() expect(mod.fromMnemonic).toBeDefined() expect(mod.derive).toBeDefined() + expect(mod.deriveRendezvous).toBeDefined() + expect(mod.RENDEZVOUS_PURPOSE).toBe('rendezvous') expect(mod.recover).toBeDefined() expect(mod.zeroise).toBeDefined() expect(mod.createBlindProof).toBeDefined() @@ -18,6 +20,8 @@ describe('subpath exports', () => { const mod = await import('../src/core.js') expect(mod.fromNsec).toBeDefined() expect(mod.derive).toBeDefined() + expect(mod.deriveRendezvous).toBeDefined() + expect(mod.RENDEZVOUS_PURPOSE).toBe('rendezvous') expect(mod.recover).toBeDefined() expect(mod.zeroise).toBeDefined() expect((mod as any).fromMnemonic).toBeUndefined() diff --git a/test/rendezvous.test.ts b/test/rendezvous.test.ts new file mode 100644 index 0000000..0d7242d --- /dev/null +++ b/test/rendezvous.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { derive, zeroise } from '../src/derive.js' +import { fromNsec } from '../src/root-nsec.js' +import { deriveRendezvous, RENDEZVOUS_PURPOSE } from '../src/rendezvous.js' + +describe('deriveRendezvous', () => { + it('uses the sole Vennel root-child purpose and preserves the requested rotation index', () => { + const root = fromNsec(new Uint8Array(32).fill(0x61)) + const rendezvous = deriveRendezvous(root, 3) + const generic = derive(root, 'rendezvous', 3) + + expect(RENDEZVOUS_PURPOSE).toBe('rendezvous') + expect(rendezvous.purpose).toBe(RENDEZVOUS_PURPOSE) + expect(rendezvous.index).toBe(3) + expect(rendezvous.publicKey).toEqual(generic.publicKey) + expect(rendezvous.privateKey).toEqual(generic.privateKey) + + zeroise(rendezvous) + zeroise(generic) + root.destroy() + }) + + it('rotates by index without changing the person root', () => { + const root = fromNsec(new Uint8Array(32).fill(0x62)) + const current = deriveRendezvous(root, 0) + const rotated = deriveRendezvous(root, 1) + + expect(rotated.publicKey).not.toEqual(current.publicKey) + expect(rotated.npub).not.toBe(current.npub) + + zeroise(current) + zeroise(rotated) + root.destroy() + }) + + it('cannot derive after the root is destroyed', () => { + const root = fromNsec(new Uint8Array(32).fill(0x63)) + root.destroy() + expect(() => deriveRendezvous(root)).toThrow('destroyed') + }) +})