OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity commits / 48265eb

Add hyperident-vault bridge: load master, attest local writer device, verify chain
hyper-identity becomes the single point in the application identity layer that talks to hyperident's master vault at ~/.hyperident/. Previously hyper-identity used keet-identity-key directly with no hyperident integration; callers had to load hyperident themselves and hand keypairs in. New module lib/hyperident-vault.js exposes three pure functions: - loadHyperidentMaster({ passphrase }) reads ~/.hyperident/, returns { mnemonic, publicKeyHex, identityKey, identityKeyPair } - attestLocalDevice({ passphrase, label }) generates a fresh Ed25519 keypair and attests it under the local hyperident master via IdentityKey.bootstrap. In-memory only — caller decides where to persist the chain. - verifyChainAgainstLocalMaster({ chainB64, passphrase }) discriminated result: { ok: true, ... } or { ok: false, reason } where reason is one of 'chain-invalid' | 'no-master' | 'chain-root-mismatch'. Wired into the package as ./hyperident-vault export and re-exported from index.js. hyperident added as a file: dep on the in-tree repo. 7 brittle tests, 29 asserts. Hermetic via FileManager.setBasePath() to a tmp dir per test — never touches the user's real ~/.hyperident. The 'foreign-rooted chain rejected' test reproduces yesterday's 2026-04-27 cross-link bug shape: a chain rooted at a different master correctly returns chain-root-mismatch with both expectedRoot and actualRoot exposed. Callers (pgh, pear-git-v3) keep their existing hyper-identity imports and add these three functions when they need master-rooted attestation. No changes to existing IdentityManager / IdentityRegistry code paths. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
48265eb181c255f2e734a7d5844a8b3a23f0021d
by plankatron <fleeky@gmail.com> · 2026-04-29T19:06:48.000Z

Changed files (5) +368 -4

index.js +8 -0
……@@ -35,3 +35,11 @@ export { SSHKeyProvider } from './lib/auth/providers/ssh-key.js'
3535export { ChallengeManager } from './lib/auth/challenge-manager.js'
3636export { RateLimiter } from './lib/auth/rate-limiter.js'
3737export { SSHKeyParser } from './lib/auth/ssh-parser.js'
38
39// Hyperident master-vault bridge — load master, attest local writer
40// devices, verify chains against the local master.
41export {
42 loadHyperidentMaster,
43 attestLocalDevice,
44 verifyChainAgainstLocalMaster
45} from './lib/hyperident-vault.js'
lib/hyperident-vault.js +162 -0
……@@ -0,0 +1,162 @@
1/**
2 * hyperident-vault — bridge between hyper-identity and the hyperident
3 * master vault at ~/.hyperident/.
4 *
5 * This is the single point in the application identity layer that talks
6 * to hyperident. Callers (pgh, pear-git-v3 daemon, etc.) use these
7 * functions to:
8 *
9 * - load the local master keypair (mnemonic-derived) for attestation
10 * - generate + chain-attest a fresh writer device under that master
11 * - verify a device-chain proof resolves to the local master
12 *
13 * pgh still owns orchestration (CLI commands, persistence to
14 * ~/.pgh/linked-device.json, backup, etc.). This module only provides
15 * the identity primitives — no disk writes outside the hyperident vault
16 * itself, no daemon-specific knowledge.
17 *
18 * Why this lives in hyper-identity (not pgh): pear-git-v3 already
19 * consumes hyper-identity for its IdentityRegistry + auth providers.
20 * Putting the hyperident bridge here means BOTH consumers (pgh and
21 * pear-git-v3) call into one place when they need master-rooted
22 * attestation, instead of each duplicating the wiring.
23 */
24
25import IdentityKey from 'keet-identity-key'
26import { IdentityManager as HyperidentVault } from 'hyperident/lib/identity-manager.js'
27import crypto from 'hypercore-crypto'
28import b4a from 'b4a'
29
30/**
31 * Load the local hyperident master identity from ~/.hyperident/.
32 *
33 * @param {object} [opts]
34 * @param {string|null} [opts.passphrase] - if the vault is passphrase-protected
35 * @returns {Promise<{
36 * mnemonic: string,
37 * publicKeyHex: string,
38 * identityKey: object,
39 * identityKeyPair: { publicKey: Buffer, secretKey: Buffer }
40 * }>}
41 * @throws if no hyperident vault exists or passphrase is wrong
42 */
43export async function loadHyperidentMaster ({ passphrase = null } = {}) {
44 const data = await HyperidentVault.loadIdentity(passphrase)
45 const identityKey = await IdentityKey.from({ mnemonic: data.mnemonic })
46 return {
47 mnemonic: data.mnemonic,
48 publicKeyHex: data.publicKey,
49 identityKey,
50 identityKeyPair: identityKey.identityKeyPair
51 }
52}
53
54/**
55 * Generate a fresh Ed25519 device keypair and attest it under the local
56 * hyperident master. Returns the keypair + a chain proof binding the
57 * device to the master root.
58 *
59 * Pure: does not write anything to disk. Caller decides where the new
60 * device-chain lives (e.g. pgh writes to ~/.pgh/linked-device.json).
61 *
62 * @param {object} [opts]
63 * @param {string|null} [opts.passphrase]
64 * @param {string|null} [opts.label] - human-readable label for the new device
65 * @returns {Promise<{
66 * rootPubkeyHex: string,
67 * devicePublicKey: Buffer,
68 * deviceSecretKey: Buffer,
69 * devicePublicKeyHex: string,
70 * deviceSecretKeyHex: string,
71 * chainBuffer: Buffer,
72 * chainB64: string,
73 * label: string|null,
74 * createdAt: number
75 * }>}
76 */
77export async function attestLocalDevice ({ passphrase = null, label = null } = {}) {
78 const master = await loadHyperidentMaster({ passphrase })
79 const newDeviceKp = crypto.keyPair()
80
81 // bootstrap = chain length 1 (just the new device under master).
82 // Equivalent to attestDevice with empty proof; produces an encoded
83 // ProofEncoding Buffer that IdentityKey.verify can consume.
84 const chainBuffer = await IdentityKey.bootstrap(
85 { identity: master.identityKeyPair },
86 newDeviceKp.publicKey
87 )
88
89 return {
90 rootPubkeyHex: master.publicKeyHex,
91 devicePublicKey: newDeviceKp.publicKey,
92 deviceSecretKey: newDeviceKp.secretKey,
93 devicePublicKeyHex: b4a.toString(newDeviceKp.publicKey, 'hex'),
94 deviceSecretKeyHex: b4a.toString(newDeviceKp.secretKey, 'hex'),
95 chainBuffer,
96 chainB64: b4a.toString(chainBuffer, 'base64'),
97 label,
98 createdAt: Date.now()
99 }
100}
101
102/**
103 * Verify a chain proof resolves to the local hyperident master root.
104 *
105 * Returns a discriminated result:
106 * { ok: true, rootPubkeyHex, devicePublicKeyHex }
107 * { ok: false, reason: 'no-master', detail }
108 * { ok: false, reason: 'chain-invalid' }
109 * { ok: false, reason: 'chain-root-mismatch', expectedRoot, actualRoot }
110 *
111 * @param {object} opts
112 * @param {string} opts.chainB64 - base64-encoded chain proof to verify
113 * @param {string|null} [opts.passphrase]
114 */
115export async function verifyChainAgainstLocalMaster ({ chainB64, passphrase = null }) {
116 if (typeof chainB64 !== 'string' || chainB64.length === 0) {
117 return { ok: false, reason: 'chain-invalid' }
118 }
119
120 let master
121 try {
122 master = await loadHyperidentMaster({ passphrase })
123 } catch (err) {
124 return { ok: false, reason: 'no-master', detail: err.message }
125 }
126
127 let proof
128 try {
129 proof = b4a.from(chainB64, 'base64')
130 } catch {
131 return { ok: false, reason: 'chain-invalid' }
132 }
133 if (!proof || proof.length === 0) {
134 return { ok: false, reason: 'chain-invalid' }
135 }
136
137 // IdentityKey.verify throws (not just returns null) on malformed
138 // buffers — b4a.from is lenient with bad base64 and produces garbage.
139 let verified
140 try {
141 verified = IdentityKey.verify(proof)
142 } catch {
143 return { ok: false, reason: 'chain-invalid' }
144 }
145 if (!verified) return { ok: false, reason: 'chain-invalid' }
146
147 const actualRoot = b4a.toString(verified.identityPublicKey, 'hex')
148 if (actualRoot !== master.publicKeyHex) {
149 return {
150 ok: false,
151 reason: 'chain-root-mismatch',
152 expectedRoot: master.publicKeyHex,
153 actualRoot
154 }
155 }
156
157 return {
158 ok: true,
159 rootPubkeyHex: actualRoot,
160 devicePublicKeyHex: b4a.toString(verified.devicePublicKey, 'hex')
161 }
162}
package-lock.json +30 -1
……@@ -9,13 +9,15 @@
99 "version": "1.0.0",
1010 "license": "MIT",
1111 "dependencies": {
12 "autobase": "^7.25.1",
12 "autobase": "7.27.3",
1313 "b4a": "^1.6.4",
14 "bare-path": "^3.0.0",
1415 "bip39-mnemonic": "^2.5.0",
1516 "blind-pairing": "^2.3.1",
1617 "hyperbee": "^2.27.3",
1718 "hypercore": "^11.24.0",
1819 "hypercore-crypto": "^3.0.0",
20 "hyperident": "file:../../repos/hyperident",
1921 "hyperswarm": "^4.16.0",
2022 "keet-identity-key": "github:holepunchto/keet-identity-key",
2123 "sodium-native": "^5.0.0"
……@@ -26,6 +28,29 @@
2628 "random-access-memory": "^6.2.1"
2729 }
2830 },
31 "../../repos/hyperident": {
32 "version": "0.2.0",
33 "license": "Apache-2.0",
34 "dependencies": {
35 "b4a": "^1.6.7",
36 "bare-crypto": "^1.11.1",
37 "bare-fs": "^2.3.2",
38 "bare-os": "^2.4.2",
39 "bare-path": "^2.1.3",
40 "hypercore-id-encoding": "^1.3.0",
41 "hyperswarm": "^4.12.1",
42 "keet-identity-key": "github:holepunchto/keet-identity-key",
43 "pear-runtime": "^0.4.2",
44 "sodium-universal": "^5.0.1"
45 },
46 "bin": {
47 "hyperident": "index.js"
48 },
49 "devDependencies": {
50 "brittle": "^3.0.0",
51 "pear-interface": "^1.0.0"
52 }
53 },
2954 "node_modules/@hyperswarm/secret-stream": {
3055 "version": "6.9.1",
3156 "resolved": "https://registry.npmjs.org/@hyperswarm/secret-stream/-/secret-stream-6.9.1.tgz",
……@@ -1208,6 +1233,10 @@
12081233 "hyperschema": "^1.20.1"
12091234 }
12101235 },
1236 "node_modules/hyperident": {
1237 "resolved": "../../repos/hyperident",
1238 "link": true
1239 },
12111240 "node_modules/hyperschema": {
12121241 "version": "1.20.1",
12131242 "resolved": "https://registry.npmjs.org/hyperschema/-/hyperschema-1.20.1.tgz",
package.json +6 -3
……@@ -25,16 +25,19 @@
2525 "./auth/providers/keet-identity": "./lib/auth/providers/keet-identity.js",
2626 "./auth/providers/bearer-token": "./lib/auth/providers/bearer-token.js",
2727 "./auth/providers/ssh-key": "./lib/auth/providers/ssh-key.js",
28 "./browser": "./lib/browser-identity.js"
28 "./browser": "./lib/browser-identity.js",
29 "./hyperident-vault": "./lib/hyperident-vault.js"
2930 },
3031 "dependencies": {
31 "autobase": "^7.25.1",
32 "autobase": "7.27.3",
3233 "b4a": "^1.6.4",
34 "bare-path": "^3.0.0",
3335 "bip39-mnemonic": "^2.5.0",
3436 "blind-pairing": "^2.3.1",
37 "hyperbee": "^2.27.3",
3538 "hypercore": "^11.24.0",
3639 "hypercore-crypto": "^3.0.0",
37 "hyperbee": "^2.27.3",
40 "hyperident": "file:../../repos/hyperident",
3841 "hyperswarm": "^4.16.0",
3942 "keet-identity-key": "github:holepunchto/keet-identity-key",
4043 "sodium-native": "^5.0.0"
test/hyperident-vault.test.js +162 -0
……@@ -0,0 +1,162 @@
1/**
2 * Tests for hyperident-vault — hyper-identity's bridge to the hyperident
3 * master vault at ~/.hyperident/.
4 *
5 * Uses FileManager.setBasePath() to redirect hyperident's vault to a tmp
6 * dir per test so we never touch the user's real ~/.hyperident/.
7 */
8
9import test from 'brittle'
10import fs from 'fs'
11import path from 'path'
12import os from 'os'
13import b4a from 'b4a'
14import IdentityKey from 'keet-identity-key'
15import crypto from 'hypercore-crypto'
16
17import {
18 loadHyperidentMaster,
19 attestLocalDevice,
20 verifyChainAgainstLocalMaster
21} from '../lib/hyperident-vault.js'
22
23function mktemp (prefix = 'hyperident-vault') {
24 return fs.mkdtempSync(path.join(os.tmpdir(), prefix + '-'))
25}
26
27async function withVault (t, fn) {
28 const vault = mktemp()
29 const { FileManager } = await import('hyperident/lib/file-ops.js')
30 const prev = FileManager._customPath || null
31 FileManager.setBasePath(vault)
32 t.teardown(() => {
33 if (prev) FileManager.setBasePath(prev)
34 else FileManager._customPath = null
35 fs.rmSync(vault, { recursive: true, force: true })
36 })
37 return fn(vault)
38}
39
40async function initVault () {
41 const { IdentityManager } = await import('hyperident/lib/identity-manager.js')
42 return IdentityManager.generateIdentity(null, 'test-device')
43}
44
45test('loadHyperidentMaster returns the master keypair from ~/.hyperident/', async (t) => {
46 await withVault(t, async () => {
47 const generated = await initVault()
48 const master = await loadHyperidentMaster()
49
50 t.is(typeof master.mnemonic, 'string')
51 t.is(master.publicKeyHex.length, 64, 'master pubkey is 64 hex chars')
52 t.is(master.publicKeyHex, generated.publicKey,
53 'matches IdentityManager.generateIdentity output')
54 t.ok(master.identityKeyPair.publicKey, 'identityKeyPair.publicKey present')
55 t.is(b4a.toString(master.identityKeyPair.publicKey, 'hex'), master.publicKeyHex)
56 })
57})
58
59test('attestLocalDevice produces a chain rooted at the master', async (t) => {
60 await withVault(t, async () => {
61 await initVault()
62 const master = await loadHyperidentMaster()
63 const attested = await attestLocalDevice({ label: 'writer' })
64
65 t.is(attested.rootPubkeyHex, master.publicKeyHex,
66 'attested.rootPubkeyHex equals master root')
67 t.is(attested.devicePublicKeyHex.length, 64)
68 t.is(attested.deviceSecretKeyHex.length, 128)
69 t.is(typeof attested.chainB64, 'string')
70 t.is(attested.label, 'writer')
71
72 // Cryptographic verification — the actual contract.
73 const verified = IdentityKey.verify(attested.chainBuffer)
74 t.ok(verified, 'IdentityKey.verify accepts the produced chain')
75 t.is(b4a.toString(verified.identityPublicKey, 'hex'), master.publicKeyHex,
76 'chain identityPublicKey === master root')
77 t.is(b4a.toString(verified.devicePublicKey, 'hex'), attested.devicePublicKeyHex,
78 'chain devicePublicKey === fresh writer device pubkey')
79 })
80})
81
82test('attestLocalDevice generates a fresh keypair per call', async (t) => {
83 await withVault(t, async () => {
84 await initVault()
85 const a = await attestLocalDevice()
86 const b = await attestLocalDevice()
87 t.not(a.devicePublicKeyHex, b.devicePublicKeyHex)
88 t.is(a.rootPubkeyHex, b.rootPubkeyHex)
89 })
90})
91
92test('verifyChainAgainstLocalMaster — chain rooted at local master accepted', async (t) => {
93 await withVault(t, async () => {
94 await initVault()
95 const attested = await attestLocalDevice()
96 const result = await verifyChainAgainstLocalMaster({ chainB64: attested.chainB64 })
97
98 t.ok(result.ok)
99 t.is(result.rootPubkeyHex, attested.rootPubkeyHex)
100 t.is(result.devicePublicKeyHex, attested.devicePublicKeyHex)
101 })
102})
103
104test('verifyChainAgainstLocalMaster — foreign-rooted chain rejected (yesterday\'s bug shape)', async (t) => {
105 await withVault(t, async () => {
106 await initVault()
107 const localMaster = await loadHyperidentMaster()
108
109 // Build a totally separate "foreign" master and attest a device under it.
110 const foreignKey = await IdentityKey.from({ mnemonic: IdentityKey.generateMnemonic() })
111 const foreignDeviceKp = crypto.keyPair()
112 const foreignProof = await IdentityKey.bootstrap(
113 { identity: foreignKey.identityKeyPair },
114 foreignDeviceKp.publicKey
115 )
116 const foreignChainB64 = b4a.toString(foreignProof, 'base64')
117
118 const result = await verifyChainAgainstLocalMaster({ chainB64: foreignChainB64 })
119 t.absent(result.ok)
120 t.is(result.reason, 'chain-root-mismatch')
121 t.is(result.expectedRoot, localMaster.publicKeyHex)
122 t.not(result.actualRoot, localMaster.publicKeyHex)
123 t.is(result.actualRoot, b4a.toString(foreignKey.identityKeyPair.publicKey, 'hex'),
124 'actualRoot identifies the foreign master')
125 })
126})
127
128test('verifyChainAgainstLocalMaster — empty/invalid chainB64 reports chain-invalid', async (t) => {
129 await withVault(t, async () => {
130 await initVault()
131 const r1 = await verifyChainAgainstLocalMaster({ chainB64: '' })
132 t.absent(r1.ok)
133 t.is(r1.reason, 'chain-invalid')
134
135 const r2 = await verifyChainAgainstLocalMaster({ chainB64: 'not-base64-not-a-chain' })
136 t.absent(r2.ok)
137 t.is(r2.reason, 'chain-invalid')
138 })
139})
140
141test('verifyChainAgainstLocalMaster — no master vault reports no-master', async (t) => {
142 // Don't init the vault — verify without any master present.
143 const vault = mktemp('hyperident-vault-empty')
144 const { FileManager } = await import('hyperident/lib/file-ops.js')
145 const prev = FileManager._customPath || null
146 FileManager.setBasePath(vault)
147 t.teardown(() => {
148 if (prev) FileManager.setBasePath(prev)
149 else FileManager._customPath = null
150 fs.rmSync(vault, { recursive: true, force: true })
151 })
152
153 // Build a synthetic chain just to have something to pass in
154 const k = await IdentityKey.from({ mnemonic: IdentityKey.generateMnemonic() })
155 const dev = crypto.keyPair()
156 const proof = await IdentityKey.bootstrap({ identity: k.identityKeyPair }, dev.publicKey)
157 const chainB64 = b4a.toString(proof, 'base64')
158
159 const result = await verifyChainAgainstLocalMaster({ chainB64 })
160 t.absent(result.ok)
161 t.is(result.reason, 'no-master')
162})