Files
bare-operating-system/packages/bare-os-booter/lib/services/ssh-keygen-cli.js
T
2026-08-18 18:11:34 -04:00

174 lines
4.7 KiB
JavaScript

/**
* Ed25519 key generation for Bare OS using bare-crypto (not OpenSSH wire private format).
* Writes OpenSSH-compatible `.pub` line + a Bare-OS JSON private envelope.
*
* Usage: ssh-keygen -t ed25519 -f PATH [-N pass] [-C comment]
* Encrypted private keys use bareOsKeySchema 2 (PBKDF2-SHA256 + ChaCha20-Poly1305), aligned with account crypto profile.
*/
import bareCrypto from 'bare-crypto'
import b4a from 'b4a'
import { PBKDF2_ITERATIONS, sealBytes } from '../identity/identity-account.js'
const { pbkdf2Sync, randomFillSync } = bareCrypto
/**
* @param {string} str
*/
function utf8Bytes(str) {
return b4a.from(String(str), 'utf8')
}
/**
* @param {Uint8Array} pub32
*/
function sshEd25519PublicBlob(pub32) {
const enc = (s) => {
const b = typeof s === 'string' ? utf8Bytes(s) : s
const out = new Uint8Array(4 + b.length)
new DataView(out.buffer).setUint32(0, b.length, false)
out.set(b, 4)
return out
}
const a = enc('ssh-ed25519')
const b = enc(pub32)
const merged = new Uint8Array(a.length + b.length)
merged.set(a, 0)
merged.set(b, a.length)
return merged
}
function b64(buf) {
if (typeof Buffer !== 'undefined') return Buffer.from(buf).toString('base64')
let bin = ''
for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i])
// eslint-disable-next-line no-undef
return btoa(bin)
}
/**
* @param {Record<string, unknown>} ctx
* @param {string[]} argv
*/
export async function runSshKeygenCli(ctx, argv) {
const args = argv.slice(1)
if (
args.includes('-h') ||
args.includes('--help') ||
args.includes('-?') ||
args.length === 0
) {
ctx.console.log(
'Usage: ssh-keygen -t ed25519 -f KEYFILE [-N passphrase] [-C comment]\n' +
'Bare OS: writes KEYFILE (JSON private envelope) and KEYFILE.pub (ssh-ed25519 line).\n' +
'Passphrase: schema 2 sealed key (PBKDF2 + ChaCha20-Poly1305); empty -N for plaintext schema 1.\n'
)
return
}
let type = ''
let keyPath = ''
let pass = ''
let comment = ''
for (let i = 0; i < args.length; i++) {
const a = args[i]
if (a === '-t') type = String(args[++i] || '')
else if (a === '-f') keyPath = String(args[++i] || '')
else if (a === '-N') pass = String(args[++i] ?? '')
else if (a === '-C') comment = String(args[++i] || '')
else if (a.startsWith('-')) {
ctx.console.error('ssh-keygen: unsupported option ' + a)
ctx.exitCode = 1
return
}
}
if (type !== 'ed25519') {
ctx.console.error('ssh-keygen: only -t ed25519 is supported')
ctx.exitCode = 1
return
}
if (!keyPath) {
ctx.console.error('ssh-keygen: -f KEYFILE is required')
ctx.exitCode = 1
return
}
const vfs = ctx.vfs
const b4 = ctx.b4a
if (!vfs || typeof vfs.resolveLogical !== 'function' || !b4) {
ctx.console.error('ssh-keygen: vfs unavailable')
ctx.exitCode = 1
return
}
const { publicKey, privateKey } = bareCrypto.generateKeyPair('ed25519')
const pubRaw =
typeof publicKey.export === 'function' ? publicKey.export() : publicKey._key
const secRaw =
typeof privateKey.export === 'function'
? privateKey.export()
: privateKey._key
const blob = sshEd25519PublicBlob(pubRaw)
const pubLine =
'ssh-ed25519 ' + b64(blob) + ' ' + (comment || 'bare-os-ed25519') + '\n'
/** @type {Record<string, unknown>} */
let privEnvelope
if (pass !== '') {
const salt = new Uint8Array(16)
randomFillSync(salt)
const dk = pbkdf2Sync(
utf8Bytes(pass),
salt,
PBKDF2_ITERATIONS,
32,
'sha256'
)
const inner = JSON.stringify({
sk: b64(secRaw),
pk: b64(pubRaw)
})
const sealed = sealBytes(dk, utf8Bytes(inner))
privEnvelope = {
bareOsKeySchema: 2,
kty: 'ed25519',
kdf: 'pbkdf2-sha256',
aead: 'chacha20-poly1305',
iterations: PBKDF2_ITERATIONS,
salt: b64(salt),
sealed: b64(sealed),
comment: comment || 'bare-os-ed25519',
atMs: Date.now()
}
} else {
privEnvelope = {
bareOsKeySchema: 1,
kty: 'ed25519',
sk: b64(secRaw),
pk: b64(pubRaw),
comment: comment || 'bare-os-ed25519',
atMs: Date.now()
}
}
const privJson = JSON.stringify(privEnvelope, null, 2) + '\n'
const absKey = vfs.resolveLogical(keyPath)
const absPub = vfs.resolveLogical(keyPath + '.pub')
try {
if (typeof vfs.writeFile !== 'function') {
throw new Error('writeFile missing')
}
await vfs.writeFile(absKey, b4.from(privJson))
await vfs.writeFile(absPub, b4.from(pubLine))
ctx.console.log(
'generated keypair: ' + keyPath + ' and ' + keyPath + '.pub'
)
} catch (e) {
ctx.console.error(
'ssh-keygen: ' + ((e && /** @type {Error} */ (e).message) || String(e))
)
ctx.exitCode = 1
}
}