OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / auth / ssh-parser.js

257 lines · javascript ref:
import b4a from 'b4a'

/**
 * SSH Key Parser
 *
 * Parses SSH public keys (RFC 4253 format) and extracts RSA components.
 * Supports: ssh-rsa, ssh-ed25519
 */
export class SSHKeyParser {
  /**
   * Parse SSH public key
   * @param {string} sshPublicKey - SSH public key string (ssh-rsa AAAAB3...)
   * @returns {Object} { type, data } where data depends on type
   */
  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]

    // Decode base64
    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}`)
    }
  }

  /**
   * Parse RSA public key from SSH wire format
   * @param {Buffer} buffer - SSH wire format buffer
   * @returns {Object} { type: 'rsa', e, n }
   */
  static parseRSA(buffer) {
    let offset = 0

    // Read algorithm name
    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}`)
    }

    // Read exponent (e)
    const eLen = buffer.readUInt32BE(offset)
    offset += 4
    const e = buffer.subarray(offset, offset + eLen)
    offset += eLen

    // Read modulus (n)
    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)
    }
  }

  /**
   * Parse Ed25519 public key from SSH wire format
   * @param {Buffer} buffer - SSH wire format buffer
   * @returns {Object} { type: 'ed25519', publicKey }
   */
  static parseEd25519(buffer) {
    let offset = 0

    // Read algorithm name
    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}`)
    }

    // Read public key
    const keyLen = buffer.readUInt32BE(offset)
    offset += 4
    const publicKey = buffer.subarray(offset, offset + keyLen)

    return {
      type: 'ed25519',
      publicKey: b4a.from(publicKey)
    }
  }

  /**
   * Parse SSH signature (for RSA or Ed25519)
   * @param {string} signatureBase64 - Base64 encoded signature
   * @returns {Object} { type, signature }
   */
  static parseSignature(signatureBase64) {
    const buffer = b4a.from(signatureBase64, 'base64')
    let offset = 0

    // Read algorithm name
    const algoLen = buffer.readUInt32BE(offset)
    offset += 4
    const algorithm = buffer.toString('utf8', offset, offset + algoLen)
    offset += algoLen

    // Read signature
    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)
    }
  }

  /**
   * Convert RSA components to PEM format (for crypto module)
   * @param {Object} key - { e, n }
   * @returns {string} PEM formatted public key
   */
  static toPEM(key) {
    if (key.type !== 'rsa') {
      throw new Error('Only RSA keys can be converted to PEM')
    }

    // ASN.1 DER encoding for RSA public key
    // SEQUENCE {
    //   SEQUENCE { algorithm, null }
    //   BIT STRING { SEQUENCE { n, e } }
    // }

    const der = this.encodeRSAPublicKeyDER(key.n, key.e)
    const base64 = b4a.toString(der, 'base64')

    // Split into 64-char lines
    const lines = base64.match(/.{1,64}/g) || []

    return [
      '-----BEGIN PUBLIC KEY-----',
      ...lines,
      '-----END PUBLIC KEY-----'
    ].join('\n')
  }

  /**
   * Encode RSA public key in DER format (for PEM)
   * @param {Buffer} n - Modulus
   * @param {Buffer} e - Exponent
   * @returns {Buffer} DER encoded public key
   */
  static encodeRSAPublicKeyDER(n, e) {
    // This is a simplified DER encoder for RSA public keys
    // For production, use a proper ASN.1 encoder library

    // INTEGER encoding
    const encodeInteger = (buf) => {
      // Add leading 0x00 if high bit is set (to indicate positive)
      const needsPadding = buf[0] & 0x80
      const length = buf.length + (needsPadding ? 1 : 0)
      const result = b4a.alloc(2 + length)
      result[0] = 0x02  // INTEGER tag
      result[1] = length
      if (needsPadding) {
        result[2] = 0x00
        buf.copy(result, 3)
      } else {
        buf.copy(result, 2)
      }
      return result
    }

    // SEQUENCE of n and e
    const nInt = encodeInteger(n)
    const eInt = encodeInteger(e)
    const innerSeq = b4a.concat([nInt, eInt])
    const innerSeqLen = innerSeq.length
    const innerSeqEncoded = b4a.concat([
      b4a.from([0x30]),  // SEQUENCE tag
      this.encodeLength(innerSeqLen),
      innerSeq
    ])

    // BIT STRING wrapper
    const bitStringLen = innerSeqEncoded.length + 1
    const bitString = b4a.concat([
      b4a.from([0x03]),  // BIT STRING tag
      this.encodeLength(bitStringLen),
      b4a.from([0x00]),  // No unused bits
      innerSeqEncoded
    ])

    // Algorithm identifier (rsaEncryption OID)
    const rsaOID = b4a.from([
      0x06, 0x09,  // OID tag + length
      0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01  // 1.2.840.113549.1.1.1
    ])
    const algNull = b4a.from([0x05, 0x00])  // NULL
    const algSeq = b4a.concat([
      b4a.from([0x30]),  // SEQUENCE tag
      this.encodeLength(rsaOID.length + algNull.length),
      rsaOID,
      algNull
    ])

    // Outer SEQUENCE
    const outerSeqContent = b4a.concat([algSeq, bitString])
    const outerSeq = b4a.concat([
      b4a.from([0x30]),  // SEQUENCE tag
      this.encodeLength(outerSeqContent.length),
      outerSeqContent
    ])

    return outerSeq
  }

  /**
   * Encode ASN.1 length
   * @param {number} length - Length to encode
   * @returns {Buffer} Encoded length
   */
  static encodeLength(length) {
    if (length < 128) {
      return b4a.from([length])
    }

    // Long form
    const bytes = []
    let len = length
    while (len > 0) {
      bytes.unshift(len & 0xff)
      len >>= 8
    }
    return b4a.from([0x80 | bytes.length, ...bytes])
  }
}