/**
* SSH identity integration for pear-git-v3.
*
* Bridges hyper-identity's SSH primitives to v3's auth layer:
*
* - Principal format: `ssh:<fingerprint>` (the OpenSSH SHA256 fingerprint)
* stored as the identityId in IdentityRegistry. The registry already
* maintains a reverse index `sshkey:<fingerprint> → identityId` so
* lookups are direct.
*
* - Auto-import: at daemon boot AND after first-user TOFU, scan the
* running user's `~/.ssh/*.pub`, register each as an `ssh-key`
* Identity linked to the owner User. Delegates to
* `hyper-identity/auth/ssh-auto-import`.
*
* - Verify: `verifySSHSignature` (unchanged) — validates an SSH
* signature against a challenge, used by the `auth-verify-ssh`
* handler.
*
* - SSH auth (future CLI): session.publicKey will be "ssh:<fp>" for
* SSH-signed sessions. `requireWriter` resolves this via
* `registry.getUserByIdentity` same as browser identities.
*/
import { SSHKeyParser } from 'hyper-identity/auth/ssh-parser'
import { scanHomeSSHKeys } from 'hyper-identity/auth/ssh-scan'
import { autoImportLocalSSHKeys } from 'hyper-identity/auth/ssh-auto-import'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
/**
* Produce the trust-map-compatible principal for an SSH public key.
*
* Kept for backward compatibility with the F.5 auth-verify-ssh handler
* and any existing trust-map data on disk. The registry uses the
* full "ssh:<fingerprint>" identityId instead; both resolve to the
* same identity via the registry's sshkey reverse index.
*
* @param {string} sshPublicKey - "ssh-ed25519 AAAA... user@host"
* @returns {string} 64 hex chars (sha256 of the decoded pubkey blob)
*/
export function sshPrincipal (sshPublicKey) {
const parts = sshPublicKey.trim().split(/\s+/)
if (parts.length < 2) throw new Error('Invalid SSH key format')
const blob = b4a.from(parts[1], 'base64')
const digest = crypto.hash(blob)
return b4a.toString(digest, 'hex')
}
/**
* Verify an SSH signature against a challenge. Pure signature check;
* does not consult any registry or trust data.
*
* @param {string} sshPublicKey - SSH pubkey text
* @param {string} signatureBase64 - SSH-wire-format signature blob, base64
* @param {string|Buffer} challenge - message that was signed
* @returns {Promise<boolean>}
*/
export async function verifySSHSignature (sshPublicKey, signatureBase64, challenge) {
try {
const parsedKey = SSHKeyParser.parse(sshPublicKey)
const parsedSig = SSHKeyParser.parseSignature(signatureBase64)
const msg = typeof challenge === 'string' ? b4a.from(challenge, 'utf8') : challenge
if (parsedKey.type === 'ed25519') {
return crypto.verify(msg, parsedSig.signature, parsedKey.publicKey)
}
if (parsedKey.type === 'rsa') {
const nodeCrypto = await import('node:crypto')
const pem = SSHKeyParser.toPEM(parsedKey)
const verifier = nodeCrypto.createVerify('SHA256')
verifier.update(msg)
return verifier.verify(pem, parsedSig.signature)
}
return false
} catch {
return false
}
}
/**
* Find the current daemon owner's userId. Returns the userId of the
* first user with 'admin' in globalPermissions, or null if no admin
* exists yet (pre-TOFU).
*
* @param {IdentityRegistry} registry
* @returns {Promise<string|null>}
*/
export async function findOwnerUserId (registry) {
if (!registry) return null
const users = await registry.listUsers().catch(() => [])
for (const u of users) {
if (Array.isArray(u.globalPermissions) && u.globalPermissions.includes('admin')) {
return u.userId
}
}
return null
}
/**
* Auto-import local SSH public keys into the IdentityRegistry as
* `method: 'ssh-key'` identities linked to the daemon owner.
*
* Delegates the scan + register flow to hyper-identity's helper; this
* wrapper just resolves the owner userId and gates on svc state.
*
* @param {object} svc
* @param {object} [opts]
* @param {boolean} [opts.allowRoot=false]
* @returns {Promise<{ownerUserId, scanned, imported, skipped, reason?}>}
*/
export async function autoImportSSHKeys (svc, opts = {}) {
const registry = svc && svc.identityRegistry
if (!registry) throw new Error('autoImportSSHKeys requires svc.identityRegistry')
const ownerUserId = await findOwnerUserId(registry)
if (!ownerUserId) {
return { ownerUserId: null, scanned: 0, imported: 0, skipped: 0, reason: 'no-owner-yet' }
}
// autoImportLocalSSHKeys expects a verified-ownership registry that
// requires the owner User to already exist and have at least one
// non-ssh-key identity — matches our TOFU order (web-identity is
// always registered before the post-TOFU auto-import fires).
const result = await autoImportLocalSSHKeys(registry, ownerUserId, opts)
return { ownerUserId, ...result }
}
/**
* List SSH keys currently linked to a user. Reads the User's
* identities via the registry, filters `method === 'ssh-key'`, and
* enriches each entry with the comment / full pubkey found on disk
* (registry stores credentials.fingerprint+type; the human-readable
* `user@host` comment lives in the `.pub` file).
*
* @param {object} svc
* @param {string} userId
* @returns {Promise<Array<{fingerprint, type, comment, publicKey, path, identityId}>>}
*/
export async function listLinkedSSHKeys (svc, userId) {
const registry = svc && svc.identityRegistry
if (!registry || !userId) return []
const { user, identities } = await registry.getUserWithIdentities(userId).catch(() => ({}))
if (!user || !identities) return []
const sshIdentities = identities.filter(i => i.method === 'ssh-key')
if (sshIdentities.length === 0) return []
// Enrich with on-disk comments. The scan is cheap (a few files).
// If $HOME/.ssh/ isn't readable we just return identities without
// comment/path info.
const diskKeys = await scanHomeSSHKeys({ allowRoot: true }).catch(() => [])
const byFingerprint = new Map(diskKeys.map(k => [k.fingerprint, k]))
return sshIdentities.map(id => {
const fp = id.credentials && id.credentials.fingerprint
const diskMatch = fp ? byFingerprint.get(fp) : null
return {
identityId: id.identityId,
fingerprint: fp,
type: id.credentials && id.credentials.type,
comment: diskMatch ? diskMatch.comment : (id.credentials && id.credentials.comment) || null,
publicKey: diskMatch ? diskMatch.publicKey : id.publicKey,
path: diskMatch ? diskMatch.path : null
}
})
}