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