OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / auth / providers / ssh-key.js

229 lines · javascript ref:
import { AuthProvider } from '../base-provider.js'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import { SSHKeyParser } from '../ssh-parser.js'

// Try to import node:crypto (works in Node, not in Bare)
let nodeCrypto
try {
  nodeCrypto = await import('node:crypto')
} catch {
  nodeCrypto = null
}

/**
 * SSHKeyProvider
 *
 * Authenticates users via SSH public keys:
 * - SSH keys must be linked to a user account first
 * - Looks for X-SSH-Signature header with signed challenge
 * - Used for git operations over HTTP with SSH authentication
 */
export class SSHKeyProvider extends AuthProvider {
  constructor(registry) {
    super(registry)
    this.name = 'ssh-key'
  }

  /**
   * Extract SSH signature from request headers
   * @param {Object} req - HTTP request
   * @returns {Promise<Object|null>} { publicKey, signature, challenge } or null
   */
  async extractCredentials(req) {
    const sshSignature = req.headers['x-ssh-signature']
    const sshPublicKey = req.headers['x-ssh-publickey']
    const sshChallenge = req.headers['x-ssh-challenge']

    if (!sshSignature || !sshPublicKey || !sshChallenge) {
      return null
    }

    return {
      publicKey: sshPublicKey,
      signature: sshSignature,
      challenge: sshChallenge
    }
  }

  /**
   * Authenticate using SSH key signature
   * @param {Object} credentials - { publicKey, signature, challenge, challengeId }
   * @returns {Promise<Object>} { identityId, method, verified }
   */
  async authenticate(credentials) {
    const { publicKey, signature, challenge, challengeId } = credentials

    // Calculate fingerprint (same as registry uses)
    const fingerprint = this.calculateSSHFingerprint(publicKey)

    // Look up identity by SSH key
    const identity = await this.registry.getIdentityBySSHKey(publicKey)

    if (!identity) {
      throw new Error('SSH key not registered. Link your SSH key via web interface first.')
    }

    // Check if identity is revoked
    const isRevoked = await this.registry.isRevoked(identity.identityId)
    if (isRevoked) {
      throw new Error('SSH key has been revoked')
    }

    // Verify signature
    const isValid = await this.verifySignature(publicKey, signature, challenge)
    if (!isValid) {
      throw new Error('Invalid SSH signature')
    }

    return {
      identityId: identity.identityId,
      method: 'ssh-key',
      verified: true,
      credentials: { publicKey, fingerprint }
    }
  }

  /**
   * Verify SSH signature (RSA or Ed25519)
   * @param {string} publicKey - SSH public key
   * @param {string} signature - Base64 signature (SSH wire format)
   * @param {string} challenge - Challenge string
   * @returns {Promise<boolean>} True if valid
   */
  async verifySignature(publicKey, signature, challenge) {
    try {
      // Parse SSH public key
      const parsedKey = SSHKeyParser.parse(publicKey)

      // Parse SSH signature
      const parsedSig = SSHKeyParser.parseSignature(signature)

      // Verify key type matches signature type
      if (parsedKey.type !== parsedSig.type) {
        console.error('[SSH] Key type mismatch:', parsedKey.type, 'vs', parsedSig.type)
        return false
      }

      const challengeBuf = b4a.from(challenge, 'utf8')

      // Verify based on key type
      if (parsedKey.type === 'rsa') {
        return this.verifyRSA(parsedKey, parsedSig.signature, challengeBuf)
      } else if (parsedKey.type === 'ed25519') {
        return this.verifyEd25519(parsedKey, parsedSig.signature, challengeBuf)
      } else {
        console.error('[SSH] Unsupported key type:', parsedKey.type)
        return false
      }
    } catch (err) {
      console.error('[SSH] Signature verification error:', err)
      return false
    }
  }

  /**
   * Verify RSA signature (PKCS#1 v1.5 with SHA-256)
   * @param {Object} key - Parsed RSA key { e, n }
   * @param {Buffer} signature - Raw signature bytes
   * @param {Buffer} data - Data that was signed
   * @returns {boolean} True if valid
   */
  verifyRSA(key, signature, data) {
    // Check if node:crypto is available (Node.js environment)
    if (!nodeCrypto) {
      console.warn('[SSH] RSA verification not available in Bare runtime (use Node.js for full verification)')
      // Fall back to basic validation
      return this.validateRSASignatureFormat(key, signature)
    }

    try {
      // Convert SSH key format to PEM for node:crypto
      const pem = SSHKeyParser.toPEM(key)

      // Create verifier
      const verifier = nodeCrypto.default.createVerify('RSA-SHA256')
      verifier.update(data)

      // Verify signature
      const isValid = verifier.verify(pem, signature)

      if (!isValid) {
        console.warn('[SSH] RSA signature verification failed')
      }

      return isValid
    } catch (err) {
      console.error('[SSH] RSA verification error:', err)
      return false
    }
  }

  /**
   * Validate RSA signature format (fallback for Bare runtime)
   *
   * SECURITY: Cannot verify RSA signatures without node:crypto.
   * Always returns false to prevent authentication bypass.
   *
   * @param {Object} key - Parsed RSA key { e, n }
   * @param {Buffer} signature - Raw signature bytes
   * @returns {boolean} Always false - RSA auth requires Node.js
   */
  validateRSASignatureFormat(key, signature) {
    console.error('[SSH] SECURITY: RSA signature verification not available in Bare runtime.')
    console.error('[SSH] RSA authentication requires Node.js. Use Ed25519 keys instead.')
    return false
  }

  /**
   * Verify Ed25519 signature
   * @param {Object} key - Parsed Ed25519 key { publicKey }
   * @param {Buffer} signature - Raw signature bytes (64 bytes)
   * @param {Buffer} data - Data that was signed
   * @returns {boolean} True if valid
   */
  verifyEd25519(key, signature, data) {
    try {
      // Validate sizes
      if (key.publicKey.length !== 32) {
        console.error('[SSH] Invalid Ed25519 public key size:', key.publicKey.length)
        return false
      }

      if (signature.length !== 64) {
        console.error('[SSH] Invalid Ed25519 signature size:', signature.length)
        return false
      }

      // Use hypercore-crypto for Ed25519 verification
      const isValid = crypto.verify(data, signature, key.publicKey)

      if (!isValid) {
        console.warn('[SSH] Ed25519 signature verification failed')
      }

      return isValid
    } catch (err) {
      console.error('[SSH] Ed25519 verification error:', err)
      return false
    }
  }

  /**
   * Calculate SHA256 fingerprint of SSH public key
   * @param {string} sshPublicKey - SSH public key (ssh-rsa ...)
   * @returns {string} Hex fingerprint
   */
  calculateSSHFingerprint(sshPublicKey) {
    return crypto.hash(b4a.from(sshPublicKey)).toString('hex').substring(0, 32)
  }

  /**
   * SSH keys cannot be generated by the server
   * Users must provide their own SSH public key
   */
  async generateIdentity(options = {}) {
    throw new Error('SSH keys cannot be generated server-side. Use ssh-keygen to create a key.')
  }
}