import { BaseProvider } from '../base-provider.js'
import { SSHKeyParser } from '../ssh-parser.js'
import { ANProto } from '../../anproto.js'
import b4a from 'b4a'
/**
* SSHKeyProvider — SSH key authentication
*
* Verifies Ed25519 and RSA SSH key signatures.
* Requires node:crypto for RSA verification (Ed25519 uses hypercore-crypto).
*/
export class SSHKeyProvider extends BaseProvider {
constructor (identityRegistry, challengeManager) {
super('ssh-key')
this.registry = identityRegistry
this.challengeManager = challengeManager
}
async extractCredentials (req) {
const sshPublicKey = req.headers['x-ssh-publickey']
const sshSignature = req.headers['x-ssh-signature']
const challengeId = req.headers['x-ssh-challenge']
if (!sshPublicKey || !sshSignature || !challengeId) return null
return { sshPublicKey, sshSignature, challengeId }
}
async authenticate (credentials) {
const { sshPublicKey, sshSignature, challengeId } = credentials
// Look up the challenge by ID, then verify the signature against the challenge value
const challenge = await this.challengeManager.get(challengeId)
if (!challenge) throw new Error('Invalid or expired challenge')
// Parse SSH key
const parsedKey = SSHKeyParser.parse(sshPublicKey)
const parsedSig = SSHKeyParser.parseSignature(sshSignature)
let valid = false
if (parsedKey.type === 'ed25519') {
// SSH Ed25519: raw detached verify (NOT ANProto wire format — SSH has its own format)
try {
const msg = typeof challenge.challenge === 'string' ? b4a.from(challenge.challenge, 'utf8') : challenge.challenge
const sig = parsedSig.signature
const pk = typeof parsedKey.publicKey === 'string' ? b4a.from(parsedKey.publicKey, 'base64') : parsedKey.publicKey
const crypto = await import('hypercore-crypto')
valid = crypto.default ? crypto.default.verify(msg, sig, pk) : crypto.verify(msg, sig, pk)
} catch {
valid = false
}
} else if (parsedKey.type === 'rsa') {
// RSA requires node:crypto
try {
const nodeCrypto = await import('node:crypto')
const pem = SSHKeyParser.toPEM(parsedKey)
const verifier = nodeCrypto.createVerify('SHA256')
verifier.update(challenge.challenge)
valid = verifier.verify(pem, parsedSig.signature)
} catch {
throw new Error('RSA verification requires node:crypto')
}
}
if (!valid) throw new Error('Invalid SSH signature')
// Consume the challenge (single-use)
await this.challengeManager.consume(challengeId)
// Use SSH public key string as identity ID
const identityId = `ssh:${sshPublicKey.substring(0, 64)}`
let identity = await this.registry.getIdentity(identityId)
// Check revocation before allowing auth
if (identity) {
const revoked = await this.registry.isRevoked(identityId)
if (revoked) throw new Error('Identity has been revoked')
}
if (!identity) {
// Compute fingerprint for reverse lookup index
const fingerprint = SSHKeyParser.fingerprint
? SSHKeyParser.fingerprint(sshPublicKey)
: `ssh:${sshPublicKey.substring(0, 64)}`
await this.registry.registerIdentity({
identityId,
method: 'ssh-key',
publicKey: sshPublicKey,
credentials: { fingerprint, type: parsedKey.type },
keyType: parsedKey.type,
createdAt: Date.now()
})
identity = { identityId, method: 'ssh-key' }
}
return {
identityId,
method: 'ssh-key',
credentials: { sshPublicKey, keyType: parsedKey.type }
}
}
}
export default SSHKeyProvider