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])
}
}