OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity commits / 1f1a901

hyperident-vault: add exportMasterBundle / importMasterBundle
Phase B groundwork — pgh's CLI export/import commands will call into these. Two thin wrappers around hyperident.IdentityManager's exportDeviceBundle / importDeviceBundle so callers don't import hyperident directly. - exportMasterBundle({ passphrase }) returns the bundle (mnemonic + deviceChain + profiles); the caller is responsible for treating the result as sensitive material (it contains the cleartext mnemonic unless re-encrypted) - importMasterBundle({ bundle, deviceName, passphrase }) destructive on the receiving vault — overwrites ~/.hyperident/identity with the bundle's mnemonic and writes a fresh device chain attested under that newly-shared master Tests (3 new, 10 total in this module, 42/42 asserts): - exportMasterBundle returns a bundle containing the master - exportMasterBundle → importMasterBundle round-trip reproduces the same master root + mnemonic on a fresh vault, AND the receiver can subsequently attest fresh devices that verify locally - importMasterBundle rejects malformed input Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1f1a901ab96a201b5f0f3a9e28f726ecd520c111
by plankatron <fleeky@gmail.com> · 2026-04-29T22:09:25.000Z

Changed files (3) +130 -3

index.js +5 -2
……@@ -37,9 +37,12 @@ export { RateLimiter } from './lib/auth/rate-limiter.js'
3737export { SSHKeyParser } from './lib/auth/ssh-parser.js'
3838
3939// Hyperident master-vault bridge — load master, attest local writer
40// devices, verify chains against the local master.
40// devices, verify chains against the local master, export/import the
41// master vault between machines.
4142export {
4243 loadHyperidentMaster,
4344 attestLocalDevice,
44 verifyChainAgainstLocalMaster
45 verifyChainAgainstLocalMaster,
46 exportMasterBundle,
47 importMasterBundle
4548} from './lib/hyperident-vault.js'
lib/hyperident-vault.js +48 -0
……@@ -99,6 +99,54 @@ export async function attestLocalDevice ({ passphrase = null, label = null } = {
9999 }
100100}
101101
102/**
103 * Export the local hyperident master vault as a bundle suitable for
104 * transfer to another machine. The bundle contains the mnemonic — treat
105 * the returned object as sensitive material; callers should write it to
106 * a secured file path and wipe afterwards (or pass through a
107 * passphrase-encrypted form).
108 *
109 * Thin wrapper over hyperident's IdentityManager.exportDeviceBundle so
110 * callers (pgh) don't import hyperident directly.
111 *
112 * @param {object} [opts]
113 * @param {string|null} [opts.passphrase] - if the local vault is encrypted
114 * @returns {Promise<object>} the bundle (mnemonic + deviceChain + profiles)
115 */
116export async function exportMasterBundle ({ passphrase = null } = {}) {
117 return HyperidentVault.exportDeviceBundle(passphrase)
118}
119
120/**
121 * Import a master-vault bundle into the local ~/.hyperident/. After
122 * import, the local hyperident vault holds the bundle's mnemonic — same
123 * master root as the exporter — and a fresh device keypair attested
124 * under it.
125 *
126 * Destructive on the receiving machine: overwrites
127 * ~/.hyperident/identity. Caller is responsible for backing up the
128 * existing vault before invoking.
129 *
130 * Thin wrapper over hyperident's IdentityManager.importDeviceBundle.
131 *
132 * @param {object} opts
133 * @param {object} opts.bundle - the bundle returned by exportMasterBundle
134 * @param {string} opts.deviceName - human-readable name for the receiver's
135 * fresh device (e.g. 'stortron')
136 * @param {string|null} [opts.passphrase] - if the receiver wants to
137 * encrypt the imported vault
138 * @returns {Promise<void>}
139 */
140export async function importMasterBundle ({ bundle, deviceName, passphrase = null }) {
141 if (!bundle || typeof bundle !== 'object') {
142 throw new Error('importMasterBundle requires a bundle object')
143 }
144 if (!deviceName || typeof deviceName !== 'string') {
145 throw new Error('importMasterBundle requires a deviceName')
146 }
147 return HyperidentVault.importDeviceBundle(bundle, deviceName, passphrase)
148}
149
102150/**
103151 * Verify a chain proof resolves to the local hyperident master root.
104152 *
test/hyperident-vault.test.js +77 -1
……@@ -17,7 +17,9 @@ import crypto from 'hypercore-crypto'
1717import {
1818 loadHyperidentMaster,
1919 attestLocalDevice,
20 verifyChainAgainstLocalMaster
20 verifyChainAgainstLocalMaster,
21 exportMasterBundle,
22 importMasterBundle
2123} from '../lib/hyperident-vault.js'
2224
2325function mktemp (prefix = 'hyperident-vault') {
……@@ -138,6 +140,80 @@ test('verifyChainAgainstLocalMaster — empty/invalid chainB64 reports chain-inv
138140 })
139141})
140142
143test('exportMasterBundle returns a bundle containing the mnemonic + master pubkey', async (t) => {
144 await withVault(t, async () => {
145 await initVault()
146 const master = await loadHyperidentMaster()
147 const bundle = await exportMasterBundle()
148
149 t.ok(bundle, 'bundle returned')
150 t.ok(bundle.identity, 'bundle has identity field')
151 t.is(typeof bundle.identity.mnemonic, 'string', 'mnemonic is a string')
152 t.is(bundle.identity.publicKey, master.publicKeyHex,
153 'bundle.identity.publicKey === local master root')
154 t.ok(bundle.deviceChain, 'bundle has deviceChain field')
155 t.ok(bundle.deviceChain.keyPair, 'deviceChain has keyPair')
156 t.ok(bundle.deviceChain.proof, 'deviceChain has proof')
157 })
158})
159
160test('exportMasterBundle → importMasterBundle reproduces the same master root on a fresh vault', async (t) => {
161 // Two completely independent vaults. Vault A initializes, exports its
162 // bundle, vault B imports it. After import, both vaults must hold the
163 // same master root.
164 const vaultA = mktemp('hyperident-vault-A')
165 const vaultB = mktemp('hyperident-vault-B')
166 const { FileManager } = await import('hyperident/lib/file-ops.js')
167 const prev = FileManager._customPath || null
168 t.teardown(() => {
169 if (prev) FileManager.setBasePath(prev)
170 else FileManager._customPath = null
171 fs.rmSync(vaultA, { recursive: true, force: true })
172 fs.rmSync(vaultB, { recursive: true, force: true })
173 })
174
175 // Init A and capture its master + bundle
176 FileManager.setBasePath(vaultA)
177 await initVault()
178 const masterA = await loadHyperidentMaster()
179 const bundle = await exportMasterBundle()
180
181 // Switch to B (no init — empty vault) and import the bundle
182 FileManager.setBasePath(vaultB)
183 await importMasterBundle({ bundle, deviceName: 'machine-B' })
184 const masterB = await loadHyperidentMaster()
185
186 t.is(masterB.publicKeyHex, masterA.publicKeyHex,
187 'after import, vault B holds the same master root as vault A')
188 t.is(masterB.mnemonic, masterA.mnemonic,
189 'after import, vault B holds the same mnemonic as vault A')
190
191 // The receiver should also be able to attest a fresh device under the
192 // shared master — confirming the import wired up everything keet-id-key
193 // needs to chain new devices.
194 const attested = await attestLocalDevice({ label: 'machine-B-writer' })
195 t.is(attested.rootPubkeyHex, masterA.publicKeyHex,
196 'B can attest a fresh device under the shared master')
197
198 // And the chain it produces must verify against the shared master
199 const verified = await verifyChainAgainstLocalMaster({ chainB64: attested.chainB64 })
200 t.ok(verified.ok, 'B-attested chain verifies against B\'s (now shared) local master')
201})
202
203test('importMasterBundle rejects malformed input', async (t) => {
204 await withVault(t, async () => {
205 await initVault()
206 await t.exception(
207 () => importMasterBundle({ bundle: null, deviceName: 'x' }),
208 /requires a bundle object/
209 )
210 await t.exception(
211 () => importMasterBundle({ bundle: { identity: {} }, deviceName: '' }),
212 /requires a deviceName/
213 )
214 })
215})
216
141217test('verifyChainAgainstLocalMaster — no master vault reports no-master', async (t) => {
142218 // Don't init the vault — verify without any master present.
143219 const vault = mktemp('hyperident-vault-empty')