| … | … | @@ -35,3 +35,11 @@ export { SSHKeyProvider } from './lib/auth/providers/ssh-key.js' |
| 35 | 35 | export { ChallengeManager } from './lib/auth/challenge-manager.js' |
| 36 | 36 | export { RateLimiter } from './lib/auth/rate-limiter.js' |
| 37 | 37 | export { 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. | |
| 41 | export { | |
| 42 | loadHyperidentMaster, | |
| 43 | attestLocalDevice, | |
| 44 | verifyChainAgainstLocalMaster | |
| 45 | } from './lib/hyperident-vault.js' |
| … | … | @@ -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 | ||
| 25 | import IdentityKey from 'keet-identity-key' | |
| 26 | import { IdentityManager as HyperidentVault } from 'hyperident/lib/identity-manager.js' | |
| 27 | import crypto from 'hypercore-crypto' | |
| 28 | import 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 | */ | |
| 43 | export 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 | */ | |
| 77 | export 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 | */ | |
| 115 | export 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 | } |
| … | … | @@ -9,13 +9,15 @@ |
| 9 | 9 | "version": "1.0.0", |
| 10 | 10 | "license": "MIT", |
| 11 | 11 | "dependencies": { |
| 12 | "autobase": "^7.25.1", | |
| 12 | "autobase": "7.27.3", | |
| 13 | 13 | "b4a": "^1.6.4", |
| 14 | "bare-path": "^3.0.0", | |
| 14 | 15 | "bip39-mnemonic": "^2.5.0", |
| 15 | 16 | "blind-pairing": "^2.3.1", |
| 16 | 17 | "hyperbee": "^2.27.3", |
| 17 | 18 | "hypercore": "^11.24.0", |
| 18 | 19 | "hypercore-crypto": "^3.0.0", |
| 20 | "hyperident": "file:../../repos/hyperident", | |
| 19 | 21 | "hyperswarm": "^4.16.0", |
| 20 | 22 | "keet-identity-key": "github:holepunchto/keet-identity-key", |
| 21 | 23 | "sodium-native": "^5.0.0" |
| … | … | @@ -26,6 +28,29 @@ |
| 26 | 28 | "random-access-memory": "^6.2.1" |
| 27 | 29 | } |
| 28 | 30 | }, |
| 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 | }, | |
| 29 | 54 | "node_modules/@hyperswarm/secret-stream": { |
| 30 | 55 | "version": "6.9.1", |
| 31 | 56 | "resolved": "https://registry.npmjs.org/@hyperswarm/secret-stream/-/secret-stream-6.9.1.tgz", |
| … | … | @@ -1208,6 +1233,10 @@ |
| 1208 | 1233 | "hyperschema": "^1.20.1" |
| 1209 | 1234 | } |
| 1210 | 1235 | }, |
| 1236 | "node_modules/hyperident": { | |
| 1237 | "resolved": "../../repos/hyperident", | |
| 1238 | "link": true | |
| 1239 | }, | |
| 1211 | 1240 | "node_modules/hyperschema": { |
| 1212 | 1241 | "version": "1.20.1", |
| 1213 | 1242 | "resolved": "https://registry.npmjs.org/hyperschema/-/hyperschema-1.20.1.tgz", |
| … | … | @@ -25,16 +25,19 @@ |
| 25 | 25 | "./auth/providers/keet-identity": "./lib/auth/providers/keet-identity.js", |
| 26 | 26 | "./auth/providers/bearer-token": "./lib/auth/providers/bearer-token.js", |
| 27 | 27 | "./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" | |
| 29 | 30 | }, |
| 30 | 31 | "dependencies": { |
| 31 | "autobase": "^7.25.1", | |
| 32 | "autobase": "7.27.3", | |
| 32 | 33 | "b4a": "^1.6.4", |
| 34 | "bare-path": "^3.0.0", | |
| 33 | 35 | "bip39-mnemonic": "^2.5.0", |
| 34 | 36 | "blind-pairing": "^2.3.1", |
| 37 | "hyperbee": "^2.27.3", | |
| 35 | 38 | "hypercore": "^11.24.0", |
| 36 | 39 | "hypercore-crypto": "^3.0.0", |
| 37 | "hyperbee": "^2.27.3", | |
| 40 | "hyperident": "file:../../repos/hyperident", | |
| 38 | 41 | "hyperswarm": "^4.16.0", |
| 39 | 42 | "keet-identity-key": "github:holepunchto/keet-identity-key", |
| 40 | 43 | "sodium-native": "^5.0.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 | ||
| 9 | import test from 'brittle' | |
| 10 | import fs from 'fs' | |
| 11 | import path from 'path' | |
| 12 | import os from 'os' | |
| 13 | import b4a from 'b4a' | |
| 14 | import IdentityKey from 'keet-identity-key' | |
| 15 | import crypto from 'hypercore-crypto' | |
| 16 | ||
| 17 | import { | |
| 18 | loadHyperidentMaster, | |
| 19 | attestLocalDevice, | |
| 20 | verifyChainAgainstLocalMaster | |
| 21 | } from '../lib/hyperident-vault.js' | |
| 22 | ||
| 23 | function mktemp (prefix = 'hyperident-vault') { | |
| 24 | return fs.mkdtempSync(path.join(os.tmpdir(), prefix + '-')) | |
| 25 | } | |
| 26 | ||
| 27 | async 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 | ||
| 40 | async function initVault () { | |
| 41 | const { IdentityManager } = await import('hyperident/lib/identity-manager.js') | |
| 42 | return IdentityManager.generateIdentity(null, 'test-device') | |
| 43 | } | |
| 44 | ||
| 45 | test('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 | ||
| 59 | test('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 | ||
| 82 | test('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 | ||
| 92 | test('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 | ||
| 104 | test('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 | ||
| 128 | test('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 | ||
| 141 | test('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 | }) |