OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity / lib / auth / providers / ssh-key.js

107 lines · javascript ref:
import { BaseProvider } from '../base-provider.js'
import { SSHKeyParser } from '../ssh-parser.js'
import { ANProto } from '../../anproto.js'
import b4a from 'b4a'

/**
 * SSHKeyProvider — SSH key authentication
 *
 * Verifies Ed25519 and RSA SSH key signatures.
 * Requires node:crypto for RSA verification (Ed25519 uses hypercore-crypto).
 */
export class SSHKeyProvider extends BaseProvider {
  constructor (identityRegistry, challengeManager) {
    super('ssh-key')
    this.registry = identityRegistry
    this.challengeManager = challengeManager
  }

  async extractCredentials (req) {
    const sshPublicKey = req.headers['x-ssh-publickey']
    const sshSignature = req.headers['x-ssh-signature']
    const challengeId = req.headers['x-ssh-challenge']

    if (!sshPublicKey || !sshSignature || !challengeId) return null

    return { sshPublicKey, sshSignature, challengeId }
  }

  async authenticate (credentials) {
    const { sshPublicKey, sshSignature, challengeId } = credentials

    // Look up the challenge by ID, then verify the signature against the challenge value
    const challenge = await this.challengeManager.get(challengeId)
    if (!challenge) throw new Error('Invalid or expired challenge')

    // Parse SSH key
    const parsedKey = SSHKeyParser.parse(sshPublicKey)
    const parsedSig = SSHKeyParser.parseSignature(sshSignature)

    let valid = false

    if (parsedKey.type === 'ed25519') {
      // SSH Ed25519: raw detached verify (NOT ANProto wire format — SSH has its own format)
      try {
        const msg = typeof challenge.challenge === 'string' ? b4a.from(challenge.challenge, 'utf8') : challenge.challenge
        const sig = parsedSig.signature
        const pk = typeof parsedKey.publicKey === 'string' ? b4a.from(parsedKey.publicKey, 'base64') : parsedKey.publicKey
        const crypto = await import('hypercore-crypto')
        valid = crypto.default ? crypto.default.verify(msg, sig, pk) : crypto.verify(msg, sig, pk)
      } catch {
        valid = false
      }
    } else if (parsedKey.type === 'rsa') {
      // RSA requires node:crypto
      try {
        const nodeCrypto = await import('node:crypto')
        const pem = SSHKeyParser.toPEM(parsedKey)
        const verifier = nodeCrypto.createVerify('SHA256')
        verifier.update(challenge.challenge)
        valid = verifier.verify(pem, parsedSig.signature)
      } catch {
        throw new Error('RSA verification requires node:crypto')
      }
    }

    if (!valid) throw new Error('Invalid SSH signature')

    // Consume the challenge (single-use)
    await this.challengeManager.consume(challengeId)

    // Use SSH public key string as identity ID
    const identityId = `ssh:${sshPublicKey.substring(0, 64)}`
    let identity = await this.registry.getIdentity(identityId)

    // Check revocation before allowing auth
    if (identity) {
      const revoked = await this.registry.isRevoked(identityId)
      if (revoked) throw new Error('Identity has been revoked')
    }

    if (!identity) {
      // Compute fingerprint for reverse lookup index
      const fingerprint = SSHKeyParser.fingerprint
        ? SSHKeyParser.fingerprint(sshPublicKey)
        : `ssh:${sshPublicKey.substring(0, 64)}`

      await this.registry.registerIdentity({
        identityId,
        method: 'ssh-key',
        publicKey: sshPublicKey,
        credentials: { fingerprint, type: parsedKey.type },
        keyType: parsedKey.type,
        createdAt: Date.now()
      })
      identity = { identityId, method: 'ssh-key' }
    }

    return {
      identityId,
      method: 'ssh-key',
      credentials: { sshPublicKey, keyType: parsedKey.type }
    }
  }
}

export default SSHKeyProvider