OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity / lib / auth / ssh-parser.js

131 lines · javascript ref:
import b4a from 'b4a'
import sodium from 'sodium-native'

/**
 * SSH Key Parser
 *
 * Parses SSH public keys (RFC 4253 format) and extracts RSA components.
 * Supports: ssh-rsa, ssh-ed25519
 */
export class SSHKeyParser {
  /**
   * OpenSSH-style SHA256 fingerprint of an SSH public key.
   * Input is the `ssh-rsa AAAA... comment` format; we hash the decoded
   * key blob (the base64 middle part), base64 the digest, and prefix
   * with "SHA256:". Matches `ssh-keygen -lf key.pub`.
   *
   * @param {string} sshPublicKey
   * @returns {string} "SHA256:abc123..."
   */
  static fingerprint (sshPublicKey) {
    const parts = sshPublicKey.trim().split(/\s+/)
    if (parts.length < 2) throw new Error('Invalid SSH key format')
    const keyBlob = b4a.from(parts[1], 'base64')
    // Real SHA-256 — must match `ssh-keygen -lf`. (Previously used
    // hypercore-crypto.hash, which is BLAKE2b-256, so the digest was
    // mislabeled "SHA256:" and never matched OpenSSH fingerprints.)
    const digest = b4a.alloc(sodium.crypto_hash_sha256_BYTES)
    sodium.crypto_hash_sha256(digest, keyBlob)
    // OpenSSH strips '=' padding from base64
    return 'SHA256:' + b4a.toString(digest, 'base64').replace(/=+$/, '')
  }

  static parse (sshPublicKey) {
    const parts = sshPublicKey.trim().split(/\s+/)
    if (parts.length < 2) throw new Error('Invalid SSH key format')

    const keyType = parts[0]
    const keyData = parts[1]
    const buffer = b4a.from(keyData, 'base64')

    switch (keyType) {
      case 'ssh-rsa': return this.parseRSA(buffer)
      case 'ssh-ed25519': return this.parseEd25519(buffer)
      default: throw new Error(`Unsupported key type: ${keyType}`)
    }
  }

  static parseRSA (buffer) {
    let offset = 0
    const algoLen = buffer.readUInt32BE(offset); offset += 4
    const algorithm = buffer.toString('utf8', offset, offset + algoLen); offset += algoLen
    if (algorithm !== 'ssh-rsa') throw new Error(`Expected ssh-rsa, got ${algorithm}`)

    const eLen = buffer.readUInt32BE(offset); offset += 4
    const e = buffer.subarray(offset, offset + eLen); offset += eLen
    const nLen = buffer.readUInt32BE(offset); offset += 4
    const n = buffer.subarray(offset, offset + nLen)

    return { type: 'rsa', e: b4a.from(e), n: b4a.from(n) }
  }

  static parseEd25519 (buffer) {
    let offset = 0
    const algoLen = buffer.readUInt32BE(offset); offset += 4
    const algorithm = buffer.toString('utf8', offset, offset + algoLen); offset += algoLen
    if (algorithm !== 'ssh-ed25519') throw new Error(`Expected ssh-ed25519, got ${algorithm}`)

    const keyLen = buffer.readUInt32BE(offset); offset += 4
    const publicKey = buffer.subarray(offset, offset + keyLen)
    return { type: 'ed25519', publicKey: b4a.from(publicKey) }
  }

  static parseSignature (signatureBase64) {
    const buffer = b4a.from(signatureBase64, 'base64')
    let offset = 0
    const algoLen = buffer.readUInt32BE(offset); offset += 4
    const algorithm = buffer.toString('utf8', offset, offset + algoLen); offset += algoLen
    const sigLen = buffer.readUInt32BE(offset); offset += 4
    const signature = buffer.subarray(offset, offset + sigLen)

    let type
    if (algorithm === 'ssh-rsa') type = 'rsa'
    else if (algorithm === 'ssh-ed25519') type = 'ed25519'
    else throw new Error(`Unsupported signature algorithm: ${algorithm}`)

    return { type, signature: b4a.from(signature) }
  }

  static toPEM (key) {
    if (key.type !== 'rsa') throw new Error('Only RSA keys can be converted to PEM')
    const der = this.encodeRSAPublicKeyDER(key.n, key.e)
    const base64 = b4a.toString(der, 'base64')
    const lines = base64.match(/.{1,64}/g) || []
    return ['-----BEGIN PUBLIC KEY-----', ...lines, '-----END PUBLIC KEY-----'].join('\n')
  }

  static encodeRSAPublicKeyDER (n, e) {
    const encodeInteger = (buf) => {
      const needsPadding = buf[0] & 0x80
      const length = buf.length + (needsPadding ? 1 : 0)
      const result = b4a.alloc(2 + length)
      result[0] = 0x02
      result[1] = length
      if (needsPadding) { result[2] = 0x00; buf.copy(result, 3) }
      else { buf.copy(result, 2) }
      return result
    }

    const nInt = encodeInteger(n)
    const eInt = encodeInteger(e)
    const innerSeq = b4a.concat([nInt, eInt])
    const innerSeqEncoded = b4a.concat([b4a.from([0x30]), this.encodeLength(innerSeq.length), innerSeq])
    const bitString = b4a.concat([b4a.from([0x03]), this.encodeLength(innerSeqEncoded.length + 1), b4a.from([0x00]), innerSeqEncoded])
    const rsaOID = b4a.from([0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01])
    const algNull = b4a.from([0x05, 0x00])
    const algSeq = b4a.concat([b4a.from([0x30]), this.encodeLength(rsaOID.length + algNull.length), rsaOID, algNull])
    const outerContent = b4a.concat([algSeq, bitString])
    return b4a.concat([b4a.from([0x30]), this.encodeLength(outerContent.length), outerContent])
  }

  static encodeLength (length) {
    if (length < 128) return b4a.from([length])
    const bytes = []
    let len = length
    while (len > 0) { bytes.unshift(len & 0xff); len >>= 8 }
    return b4a.from([0x80 | bytes.length, ...bytes])
  }
}

export default SSHKeyParser