/**
* hyperident-vault — bridge between hyper-identity and the hyperident
* master vault at ~/.hyperident/.
*
* This is the single point in the application identity layer that talks
* to hyperident. Callers (pgh, pear-git-v3 daemon, etc.) use these
* functions to:
*
* - load the local master keypair (mnemonic-derived) for attestation
* - generate + chain-attest a fresh writer device under that master
* - verify a device-chain proof resolves to the local master
*
* pgh still owns orchestration (CLI commands, persistence to
* ~/.pgh/linked-device.json, backup, etc.). This module only provides
* the identity primitives — no disk writes outside the hyperident vault
* itself, no daemon-specific knowledge.
*
* Why this lives in hyper-identity (not pgh): pear-git-v3 already
* consumes hyper-identity for its IdentityRegistry + auth providers.
* Putting the hyperident bridge here means BOTH consumers (pgh and
* pear-git-v3) call into one place when they need master-rooted
* attestation, instead of each duplicating the wiring.
*/
import IdentityKey from 'keet-identity-key'
import { IdentityManager as HyperidentVault } from 'hyperident/lib/identity-manager.js'
import { DeviceLinkingManager } from 'hyperident/lib/device-linking-manager.js'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
/**
* Load the local hyperident master identity from ~/.hyperident/.
*
* @param {object} [opts]
* @param {string|null} [opts.passphrase] - if the vault is passphrase-protected
* @returns {Promise<{
* mnemonic: string,
* publicKeyHex: string,
* identityKey: object,
* identityKeyPair: { publicKey: Buffer, secretKey: Buffer }
* }>}
* @throws if no hyperident vault exists or passphrase is wrong
*/
export async function loadHyperidentMaster ({ passphrase = null } = {}) {
const data = await HyperidentVault.loadIdentity(passphrase)
const identityKey = await IdentityKey.from({ mnemonic: data.mnemonic })
return {
mnemonic: data.mnemonic,
publicKeyHex: data.publicKey,
identityKey,
identityKeyPair: identityKey.identityKeyPair
}
}
/**
* Generate a fresh Ed25519 device keypair and attest it under the local
* hyperident master. Returns the keypair + a chain proof binding the
* device to the master root.
*
* Pure: does not write anything to disk. Caller decides where the new
* device-chain lives (e.g. pgh writes to ~/.pgh/linked-device.json).
*
* @param {object} [opts]
* @param {string|null} [opts.passphrase]
* @param {string|null} [opts.label] - human-readable label for the new device
* @returns {Promise<{
* rootPubkeyHex: string,
* devicePublicKey: Buffer,
* deviceSecretKey: Buffer,
* devicePublicKeyHex: string,
* deviceSecretKeyHex: string,
* chainBuffer: Buffer,
* chainB64: string,
* label: string|null,
* createdAt: number
* }>}
*/
export async function attestLocalDevice ({ passphrase = null, label = null } = {}) {
const master = await loadHyperidentMaster({ passphrase })
const newDeviceKp = crypto.keyPair()
// bootstrap = chain length 1 (just the new device under master).
// Equivalent to attestDevice with empty proof; produces an encoded
// ProofEncoding Buffer that IdentityKey.verify can consume.
const chainBuffer = await IdentityKey.bootstrap(
{ identity: master.identityKeyPair },
newDeviceKp.publicKey
)
return {
rootPubkeyHex: master.publicKeyHex,
devicePublicKey: newDeviceKp.publicKey,
deviceSecretKey: newDeviceKp.secretKey,
devicePublicKeyHex: b4a.toString(newDeviceKp.publicKey, 'hex'),
deviceSecretKeyHex: b4a.toString(newDeviceKp.secretKey, 'hex'),
chainBuffer,
chainB64: b4a.toString(chainBuffer, 'base64'),
label,
createdAt: Date.now()
}
}
/**
* Export the local hyperident master vault as a bundle suitable for
* transfer to another machine. The bundle contains the mnemonic — treat
* the returned object as sensitive material; callers should write it to
* a secured file path and wipe afterwards (or pass through a
* passphrase-encrypted form).
*
* Thin wrapper over hyperident's IdentityManager.exportDeviceBundle so
* callers (pgh) don't import hyperident directly.
*
* @param {object} [opts]
* @param {string|null} [opts.passphrase] - if the local vault is encrypted
* @returns {Promise<object>} the bundle (mnemonic + deviceChain + profiles)
*/
export async function exportMasterBundle ({ passphrase = null } = {}) {
return HyperidentVault.exportDeviceBundle(passphrase)
}
/**
* Import a master-vault bundle into the local ~/.hyperident/. After
* import, the local hyperident vault holds the bundle's mnemonic — same
* master root as the exporter — and a fresh device keypair attested
* under it.
*
* Destructive on the receiving machine: overwrites
* ~/.hyperident/identity. Caller is responsible for backing up the
* existing vault before invoking.
*
* Thin wrapper over hyperident's IdentityManager.importDeviceBundle.
*
* @param {object} opts
* @param {object} opts.bundle - the bundle returned by exportMasterBundle
* @param {string} opts.deviceName - human-readable name for the receiver's
* fresh device (e.g. 'stortron')
* @param {string|null} [opts.passphrase] - if the receiver wants to
* encrypt the imported vault
* @returns {Promise<void>}
*/
export async function importMasterBundle ({ bundle, deviceName, passphrase = null }) {
if (!bundle || typeof bundle !== 'object') {
throw new Error('importMasterBundle requires a bundle object')
}
if (!deviceName || typeof deviceName !== 'string') {
throw new Error('importMasterBundle requires a deviceName')
}
return HyperidentVault.importDeviceBundle(bundle, deviceName, passphrase)
}
/**
* Start sharing the local master vault over the network so another
* machine can join via `joinSharedDevice` below.
*
* Returns the identity public key (hex) the joining device needs.
* Uses hyperident's DeviceLinkingManager — network transport over
* Hyperswarm, same trust model as exportMasterBundle (the bundle is
* sent over the wire; the joining peer ends up with the mnemonic).
*
* Caller is responsible for stopping the share via the returned
* `stop()` callback (or letting the timeout expire).
*
* @param {object} [opts]
* @param {number} [opts.timeoutMinutes=5]
* @returns {Promise<{
* identityPublicKey: string,
* stop: () => Promise<void>
* }>}
*/
export async function shareMasterVault ({ timeoutMinutes = 5 } = {}) {
const linker = new DeviceLinkingManager()
const identityPublicKey = await linker.startSharing(timeoutMinutes)
return {
identityPublicKey,
stop: async () => { await linker.stopSharing() }
}
}
/**
* Join a master vault that another machine is currently sharing.
*
* Connects to the sharing peer over Hyperswarm, receives the bundle,
* imports it into the local ~/.hyperident/. Same trust model as
* importMasterBundle — receiver ends up with the mnemonic.
*
* Refuses to run if a local vault already exists. Caller (pgh) is
* responsible for backing up + clearing the existing vault before
* invoking if --force is set.
*
* @param {object} opts
* @param {string} opts.identityPublicKey - hex pubkey returned by
* shareMasterVault on the source
* @param {string} opts.deviceName - name for the local device
* @param {string|null} [opts.passphrase] - if the local vault should be
* encrypted after import
* @returns {Promise<boolean>} success
*/
export async function joinSharedDevice ({ identityPublicKey, deviceName, passphrase = null }) {
if (!identityPublicKey) throw new Error('joinSharedDevice requires identityPublicKey')
if (!deviceName) throw new Error('joinSharedDevice requires deviceName')
const linker = new DeviceLinkingManager()
return linker.joinDevice(identityPublicKey, deviceName, passphrase)
}
/**
* Verify a chain proof resolves to the local hyperident master root.
*
* Returns a discriminated result:
* { ok: true, rootPubkeyHex, devicePublicKeyHex }
* { ok: false, reason: 'no-master', detail }
* { ok: false, reason: 'chain-invalid' }
* { ok: false, reason: 'chain-root-mismatch', expectedRoot, actualRoot }
*
* @param {object} opts
* @param {string} opts.chainB64 - base64-encoded chain proof to verify
* @param {string|null} [opts.passphrase]
*/
export async function verifyChainAgainstLocalMaster ({ chainB64, passphrase = null }) {
if (typeof chainB64 !== 'string' || chainB64.length === 0) {
return { ok: false, reason: 'chain-invalid' }
}
let master
try {
master = await loadHyperidentMaster({ passphrase })
} catch (err) {
return { ok: false, reason: 'no-master', detail: err.message }
}
let proof
try {
proof = b4a.from(chainB64, 'base64')
} catch {
return { ok: false, reason: 'chain-invalid' }
}
if (!proof || proof.length === 0) {
return { ok: false, reason: 'chain-invalid' }
}
// IdentityKey.verify throws (not just returns null) on malformed
// buffers — b4a.from is lenient with bad base64 and produces garbage.
let verified
try {
verified = IdentityKey.verify(proof)
} catch {
return { ok: false, reason: 'chain-invalid' }
}
if (!verified) return { ok: false, reason: 'chain-invalid' }
const actualRoot = b4a.toString(verified.identityPublicKey, 'hex')
if (actualRoot !== master.publicKeyHex) {
return {
ok: false,
reason: 'chain-root-mismatch',
expectedRoot: master.publicKeyHex,
actualRoot
}
}
return {
ok: true,
rootPubkeyHex: actualRoot,
devicePublicKeyHex: b4a.toString(verified.devicePublicKey, 'hex')
}
}