544 lines
16 KiB
JavaScript
544 lines
16 KiB
JavaScript
|
|
/**
|
||
|
|
* Convert PuTTY PPK private keys into unencrypted OpenSSH PEM.
|
||
|
|
*
|
||
|
|
* ssh2 only parses PPK v2 RSA/DSA (`PuTTY-User-Key-File-2: ssh-rsa|ssh-dss`).
|
||
|
|
* Encrypted Ed25519/ECDSA keys and all PPK v3 files therefore fail even with
|
||
|
|
* the correct passphrase. We decrypt the PPK ourselves and emit OpenSSH PEM
|
||
|
|
* that ssh2 already understands.
|
||
|
|
*
|
||
|
|
* Format: https://the.earth.li/~sgtatham/putty/0.83/htmldoc/AppendixC.html
|
||
|
|
*/
|
||
|
|
|
||
|
|
const {
|
||
|
|
createCipheriv,
|
||
|
|
createDecipheriv,
|
||
|
|
createHash,
|
||
|
|
createHmac,
|
||
|
|
randomBytes,
|
||
|
|
argon2Sync,
|
||
|
|
} = require("node:crypto");
|
||
|
|
|
||
|
|
const PPK_HEADER_RE = /^\s*PuTTY-User-Key-File-([23]):\s*(\S+)\s*$/m;
|
||
|
|
const AES_BLOCK = 16;
|
||
|
|
const AES_KEY_LEN = 32;
|
||
|
|
const PPK_V2_IV = Buffer.alloc(16, 0);
|
||
|
|
const PPK_V2_SEQ0 = Buffer.from([0, 0, 0, 0]);
|
||
|
|
const PPK_V2_SEQ1 = Buffer.from([0, 0, 0, 1]);
|
||
|
|
const PPK_V3_DERIVED_LEN = 80; // 32-byte key + 16-byte IV + 32-byte MAC key
|
||
|
|
const OPENSSH_MAGIC = Buffer.from("openssh-key-v1\0");
|
||
|
|
// Caps keep a crafted PPK from stalling the Electron main process. Defaults
|
||
|
|
// from current PuTTYgen (8 MiB, ~13-34 passes, parallelism 1) sit well inside.
|
||
|
|
const ARGON2_MIN_MEMORY_KIB = 8;
|
||
|
|
const ARGON2_MAX_MEMORY_KIB = 65536;
|
||
|
|
const ARGON2_MIN_PASSES = 1;
|
||
|
|
const ARGON2_MAX_PASSES = 128;
|
||
|
|
const ARGON2_MIN_PARALLELISM = 1;
|
||
|
|
const ARGON2_MAX_PARALLELISM = 4;
|
||
|
|
const ARGON2_MIN_SALT_BYTES = 8;
|
||
|
|
const ARGON2_MAX_SALT_BYTES = 64;
|
||
|
|
|
||
|
|
const SUPPORTED_TYPES = new Set([
|
||
|
|
"ssh-ed25519",
|
||
|
|
"ssh-rsa",
|
||
|
|
"ssh-dss",
|
||
|
|
"ecdsa-sha2-nistp256",
|
||
|
|
"ecdsa-sha2-nistp384",
|
||
|
|
"ecdsa-sha2-nistp521",
|
||
|
|
]);
|
||
|
|
|
||
|
|
function isPpkPrivateKey(text) {
|
||
|
|
return typeof text === "string" && PPK_HEADER_RE.test(text);
|
||
|
|
}
|
||
|
|
|
||
|
|
function passphraseBytes(passphrase) {
|
||
|
|
if (passphrase == null) return Buffer.alloc(0);
|
||
|
|
if (Buffer.isBuffer(passphrase)) return passphrase;
|
||
|
|
return Buffer.from(String(passphrase), "utf8");
|
||
|
|
}
|
||
|
|
|
||
|
|
function writeU32(value) {
|
||
|
|
const buf = Buffer.allocUnsafe(4);
|
||
|
|
buf.writeUInt32BE(value);
|
||
|
|
return buf;
|
||
|
|
}
|
||
|
|
|
||
|
|
function writeBytes(buf) {
|
||
|
|
return Buffer.concat([writeU32(buf.length), buf]);
|
||
|
|
}
|
||
|
|
|
||
|
|
function writeString(value) {
|
||
|
|
return writeBytes(Buffer.from(value, "utf8"));
|
||
|
|
}
|
||
|
|
|
||
|
|
function readString(buf, offset) {
|
||
|
|
if (offset + 4 > buf.length) return undefined;
|
||
|
|
const length = buf.readUInt32BE(offset);
|
||
|
|
const start = offset + 4;
|
||
|
|
const end = start + length;
|
||
|
|
if (end > buf.length) return undefined;
|
||
|
|
return { value: buf.subarray(start, end), next: end };
|
||
|
|
}
|
||
|
|
|
||
|
|
function readRequiredString(buf, offset, label) {
|
||
|
|
const field = readString(buf, offset);
|
||
|
|
if (!field) {
|
||
|
|
throw new Error(`Malformed PPK ${label}`);
|
||
|
|
}
|
||
|
|
return field;
|
||
|
|
}
|
||
|
|
|
||
|
|
function wrapBase64(buf, width = 64) {
|
||
|
|
const raw = buf.toString("base64");
|
||
|
|
const lines = [];
|
||
|
|
for (let i = 0; i < raw.length; i += width) {
|
||
|
|
lines.push(raw.slice(i, i + width));
|
||
|
|
}
|
||
|
|
return lines;
|
||
|
|
}
|
||
|
|
|
||
|
|
function parsePpk(text) {
|
||
|
|
const lines = String(text).replace(/^\uFEFF/, "").split(/\r?\n/);
|
||
|
|
while (lines.length && lines[0].trim() === "") lines.shift();
|
||
|
|
const header = PPK_HEADER_RE.exec(lines[0] || "");
|
||
|
|
if (!header) return null;
|
||
|
|
|
||
|
|
const version = Number(header[1]);
|
||
|
|
const type = header[2];
|
||
|
|
let index = 1;
|
||
|
|
|
||
|
|
const readHeader = (name) => {
|
||
|
|
const line = lines[index++];
|
||
|
|
if (line == null) {
|
||
|
|
throw new Error(`Truncated PPK: missing ${name}`);
|
||
|
|
}
|
||
|
|
const match = new RegExp(`^${name}:\\s*(.*)$`).exec(line);
|
||
|
|
if (!match) {
|
||
|
|
throw new Error(`Malformed PPK: expected ${name}`);
|
||
|
|
}
|
||
|
|
return match[1];
|
||
|
|
};
|
||
|
|
|
||
|
|
const readBase64Block = (count) => {
|
||
|
|
const chunk = lines.slice(index, index + count);
|
||
|
|
if (chunk.length !== count) {
|
||
|
|
throw new Error("Truncated PPK key data");
|
||
|
|
}
|
||
|
|
index += count;
|
||
|
|
return Buffer.from(chunk.join(""), "base64");
|
||
|
|
};
|
||
|
|
|
||
|
|
const encryption = readHeader("Encryption");
|
||
|
|
const comment = readHeader("Comment");
|
||
|
|
const publicLines = Number(readHeader("Public-Lines"));
|
||
|
|
if (!Number.isInteger(publicLines) || publicLines < 1) {
|
||
|
|
throw new Error("Malformed PPK public key");
|
||
|
|
}
|
||
|
|
const publicBlob = readBase64Block(publicLines);
|
||
|
|
|
||
|
|
let kdf;
|
||
|
|
if (version === 3 && encryption !== "none") {
|
||
|
|
kdf = {
|
||
|
|
name: readHeader("Key-Derivation"),
|
||
|
|
memory: Number(readHeader("Argon2-Memory")),
|
||
|
|
passes: Number(readHeader("Argon2-Passes")),
|
||
|
|
parallelism: Number(readHeader("Argon2-Parallelism")),
|
||
|
|
saltHex: readHeader("Argon2-Salt"),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const privateLines = Number(readHeader("Private-Lines"));
|
||
|
|
if (!Number.isInteger(privateLines) || privateLines < 1) {
|
||
|
|
throw new Error("Malformed PPK private key");
|
||
|
|
}
|
||
|
|
const privateBlob = readBase64Block(privateLines);
|
||
|
|
const mac = readHeader("Private-MAC").trim().toLowerCase();
|
||
|
|
|
||
|
|
return { version, type, encryption, comment, publicBlob, privateBlob, mac, kdf };
|
||
|
|
}
|
||
|
|
|
||
|
|
function deriveV2Keys(passphrase) {
|
||
|
|
const material = Buffer.concat([
|
||
|
|
createHash("sha1").update(PPK_V2_SEQ0).update(passphrase).digest(),
|
||
|
|
createHash("sha1").update(PPK_V2_SEQ1).update(passphrase).digest(),
|
||
|
|
]);
|
||
|
|
return {
|
||
|
|
cipherKey: material.subarray(0, AES_KEY_LEN),
|
||
|
|
iv: PPK_V2_IV,
|
||
|
|
macKey: createHash("sha1").update("putty-private-key-file-mac-key").update(passphrase).digest(),
|
||
|
|
macAlgo: "sha1",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function inRange(value, min, max) {
|
||
|
|
return Number.isInteger(value) && value >= min && value <= max;
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseArgon2Salt(saltHex) {
|
||
|
|
if (typeof saltHex !== "string" || !/^[0-9a-fA-F]+$/.test(saltHex) || saltHex.length % 2 !== 0) {
|
||
|
|
throw new Error("Malformed PPK Argon2 salt");
|
||
|
|
}
|
||
|
|
const salt = Buffer.from(saltHex, "hex");
|
||
|
|
if (salt.length < ARGON2_MIN_SALT_BYTES || salt.length > ARGON2_MAX_SALT_BYTES) {
|
||
|
|
throw new Error("Malformed PPK Argon2 salt");
|
||
|
|
}
|
||
|
|
return salt;
|
||
|
|
}
|
||
|
|
|
||
|
|
function assertArgon2WorkFactors(kdf) {
|
||
|
|
if (!inRange(kdf.memory, ARGON2_MIN_MEMORY_KIB, ARGON2_MAX_MEMORY_KIB)
|
||
|
|
|| !inRange(kdf.passes, ARGON2_MIN_PASSES, ARGON2_MAX_PASSES)
|
||
|
|
|| !inRange(kdf.parallelism, ARGON2_MIN_PARALLELISM, ARGON2_MAX_PARALLELISM)) {
|
||
|
|
throw new Error(
|
||
|
|
`PPK Argon2 parameters exceed supported limits ` +
|
||
|
|
`(memory ${ARGON2_MIN_MEMORY_KIB}-${ARGON2_MAX_MEMORY_KIB} KiB, ` +
|
||
|
|
`passes ${ARGON2_MIN_PASSES}-${ARGON2_MAX_PASSES}, ` +
|
||
|
|
`parallelism ${ARGON2_MIN_PARALLELISM}-${ARGON2_MAX_PARALLELISM})`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function deriveV3Keys(passphrase, encryption, kdf) {
|
||
|
|
if (encryption === "none") {
|
||
|
|
// Unencrypted PPK v3 uses HMAC-SHA256 with an empty key. Confirmed against
|
||
|
|
// PuTTYgen 0.85 and the SshAgentLib ed25519 v3-none fixture.
|
||
|
|
return { cipherKey: null, iv: null, macKey: Buffer.alloc(0), macAlgo: "sha256" };
|
||
|
|
}
|
||
|
|
const algorithm = String(kdf?.name || "").toLowerCase();
|
||
|
|
if (algorithm !== "argon2id" && algorithm !== "argon2i" && algorithm !== "argon2d") {
|
||
|
|
throw new Error(`Unsupported PPK key derivation "${kdf?.name}"`);
|
||
|
|
}
|
||
|
|
if (!kdf) {
|
||
|
|
throw new Error("Malformed PPK Argon2 parameters");
|
||
|
|
}
|
||
|
|
assertArgon2WorkFactors(kdf);
|
||
|
|
const salt = parseArgon2Salt(kdf.saltHex);
|
||
|
|
if (typeof argon2Sync !== "function") {
|
||
|
|
throw new Error("PPK_V3_ARGON2_UNAVAILABLE");
|
||
|
|
}
|
||
|
|
const derived = argon2Sync(algorithm, {
|
||
|
|
message: passphrase,
|
||
|
|
nonce: salt,
|
||
|
|
parallelism: kdf.parallelism,
|
||
|
|
tagLength: PPK_V3_DERIVED_LEN,
|
||
|
|
memory: kdf.memory,
|
||
|
|
passes: kdf.passes,
|
||
|
|
});
|
||
|
|
return {
|
||
|
|
cipherKey: derived.subarray(0, AES_KEY_LEN),
|
||
|
|
iv: derived.subarray(AES_KEY_LEN, AES_KEY_LEN + AES_BLOCK),
|
||
|
|
macKey: derived.subarray(AES_KEY_LEN + AES_BLOCK),
|
||
|
|
macAlgo: "sha256",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function aes256Cbc(blob, key, iv, decrypt) {
|
||
|
|
const fn = decrypt ? createDecipheriv : createCipheriv;
|
||
|
|
const cipher = fn("aes-256-cbc", key, iv);
|
||
|
|
cipher.setAutoPadding(false);
|
||
|
|
return Buffer.concat([cipher.update(blob), cipher.final()]);
|
||
|
|
}
|
||
|
|
|
||
|
|
function macPayload({ type, encryption, comment, publicBlob, privateBlob }) {
|
||
|
|
return Buffer.concat([
|
||
|
|
writeString(type),
|
||
|
|
writeString(encryption),
|
||
|
|
writeString(comment),
|
||
|
|
writeBytes(publicBlob),
|
||
|
|
writeBytes(privateBlob),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
function verifyMac(parsed, keys) {
|
||
|
|
const expected = createHmac(keys.macAlgo, keys.macKey)
|
||
|
|
.update(macPayload(parsed))
|
||
|
|
.digest("hex");
|
||
|
|
return expected === parsed.mac;
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildOpenSshPrivate({ publicBlob, privateInner, comment }) {
|
||
|
|
const check = randomBytes(4);
|
||
|
|
let inner = Buffer.concat([
|
||
|
|
check,
|
||
|
|
check,
|
||
|
|
privateInner,
|
||
|
|
writeString(comment || ""),
|
||
|
|
]);
|
||
|
|
const padLen = (8 - (inner.length % 8)) % 8;
|
||
|
|
if (padLen) {
|
||
|
|
const pad = Buffer.allocUnsafe(padLen);
|
||
|
|
for (let i = 0; i < padLen; i++) pad[i] = i + 1;
|
||
|
|
inner = Buffer.concat([inner, pad]);
|
||
|
|
}
|
||
|
|
|
||
|
|
const blob = Buffer.concat([
|
||
|
|
OPENSSH_MAGIC,
|
||
|
|
writeString("none"),
|
||
|
|
writeString("none"),
|
||
|
|
writeBytes(Buffer.alloc(0)),
|
||
|
|
writeU32(1),
|
||
|
|
writeBytes(publicBlob),
|
||
|
|
writeBytes(inner),
|
||
|
|
]);
|
||
|
|
|
||
|
|
const body = wrapBase64(blob, 70).join("\n");
|
||
|
|
return `-----BEGIN OPENSSH PRIVATE KEY-----\n${body}\n-----END OPENSSH PRIVATE KEY-----\n`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function ed25519PrivateInner(publicBlob, privateBlob) {
|
||
|
|
const pubType = readRequiredString(publicBlob, 0, "public key type");
|
||
|
|
const pubKey = readRequiredString(publicBlob, pubType.next, "public key");
|
||
|
|
const seedField = readRequiredString(privateBlob, 0, "Ed25519 seed");
|
||
|
|
if (pubKey.value.length !== 32 || seedField.value.length !== 32) {
|
||
|
|
throw new Error("Malformed PPK Ed25519 key");
|
||
|
|
}
|
||
|
|
return Buffer.concat([
|
||
|
|
writeString("ssh-ed25519"),
|
||
|
|
writeBytes(pubKey.value),
|
||
|
|
writeBytes(Buffer.concat([seedField.value, pubKey.value])),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
function rsaPrivateInner(publicBlob, privateBlob) {
|
||
|
|
const pubType = readRequiredString(publicBlob, 0, "public key type");
|
||
|
|
const e = readRequiredString(publicBlob, pubType.next, "RSA e");
|
||
|
|
const n = readRequiredString(publicBlob, e.next, "RSA n");
|
||
|
|
const d = readRequiredString(privateBlob, 0, "RSA d");
|
||
|
|
const p = readRequiredString(privateBlob, d.next, "RSA p");
|
||
|
|
const q = readRequiredString(privateBlob, p.next, "RSA q");
|
||
|
|
const iqmp = readRequiredString(privateBlob, q.next, "RSA iqmp");
|
||
|
|
return Buffer.concat([
|
||
|
|
writeString("ssh-rsa"),
|
||
|
|
writeBytes(n.value),
|
||
|
|
writeBytes(e.value),
|
||
|
|
writeBytes(d.value),
|
||
|
|
writeBytes(iqmp.value),
|
||
|
|
writeBytes(p.value),
|
||
|
|
writeBytes(q.value),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
function dssPrivateInner(publicBlob, privateBlob) {
|
||
|
|
const pubType = readRequiredString(publicBlob, 0, "public key type");
|
||
|
|
const p = readRequiredString(publicBlob, pubType.next, "DSA p");
|
||
|
|
const q = readRequiredString(publicBlob, p.next, "DSA q");
|
||
|
|
const g = readRequiredString(publicBlob, q.next, "DSA g");
|
||
|
|
const y = readRequiredString(publicBlob, g.next, "DSA y");
|
||
|
|
const x = readRequiredString(privateBlob, 0, "DSA x");
|
||
|
|
return Buffer.concat([
|
||
|
|
writeString("ssh-dss"),
|
||
|
|
writeBytes(p.value),
|
||
|
|
writeBytes(q.value),
|
||
|
|
writeBytes(g.value),
|
||
|
|
writeBytes(y.value),
|
||
|
|
writeBytes(x.value),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
function ecdsaPrivateInner(type, publicBlob, privateBlob) {
|
||
|
|
const curve = type.slice("ecdsa-sha2-".length);
|
||
|
|
const pubType = readRequiredString(publicBlob, 0, "public key type");
|
||
|
|
const curveName = readRequiredString(publicBlob, pubType.next, "ECDSA curve");
|
||
|
|
const point = readRequiredString(publicBlob, curveName.next, "ECDSA point");
|
||
|
|
const scalar = readRequiredString(privateBlob, 0, "ECDSA scalar");
|
||
|
|
return Buffer.concat([
|
||
|
|
writeString(type),
|
||
|
|
writeBytes(curveName.value.length ? curveName.value : Buffer.from(curve, "utf8")),
|
||
|
|
writeBytes(point.value),
|
||
|
|
writeBytes(scalar.value),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
function privateInnerForType(type, publicBlob, privateBlob) {
|
||
|
|
switch (type) {
|
||
|
|
case "ssh-ed25519":
|
||
|
|
return ed25519PrivateInner(publicBlob, privateBlob);
|
||
|
|
case "ssh-rsa":
|
||
|
|
return rsaPrivateInner(publicBlob, privateBlob);
|
||
|
|
case "ssh-dss":
|
||
|
|
return dssPrivateInner(publicBlob, privateBlob);
|
||
|
|
case "ecdsa-sha2-nistp256":
|
||
|
|
case "ecdsa-sha2-nistp384":
|
||
|
|
case "ecdsa-sha2-nistp521":
|
||
|
|
return ecdsaPrivateInner(type, publicBlob, privateBlob);
|
||
|
|
default:
|
||
|
|
throw new Error(`Unsupported PPK key type "${type}"`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Convert a PPK private key to unencrypted OpenSSH PEM.
|
||
|
|
*
|
||
|
|
* @returns {{ privateKey: string, comment: string } | null}
|
||
|
|
* `null` when the text is not a PPK. Throws on decrypt / format errors.
|
||
|
|
*/
|
||
|
|
function convertPpkToOpenSsh(text, passphrase) {
|
||
|
|
let parsed;
|
||
|
|
try {
|
||
|
|
parsed = parsePpk(text);
|
||
|
|
} catch (err) {
|
||
|
|
if (!isPpkPrivateKey(text)) return null;
|
||
|
|
const fail = new Error(`Malformed PPK private key: ${err.message}`);
|
||
|
|
fail.code = "ERR_PPK_MALFORMED";
|
||
|
|
fail.cause = err;
|
||
|
|
throw fail;
|
||
|
|
}
|
||
|
|
if (!parsed) return null;
|
||
|
|
|
||
|
|
if (!SUPPORTED_TYPES.has(parsed.type)) {
|
||
|
|
const err = new Error(
|
||
|
|
`PuTTY keys of type "${parsed.type}" are not supported. ` +
|
||
|
|
"Export an OpenSSH key from PuTTYgen and try again.",
|
||
|
|
);
|
||
|
|
err.code = "ERR_PPK_UNSUPPORTED_TYPE";
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
|
||
|
|
const encrypted = parsed.encryption !== "none";
|
||
|
|
if (encrypted && parsed.encryption !== "aes256-cbc") {
|
||
|
|
const err = new Error(`Unsupported PPK encryption "${parsed.encryption}"`);
|
||
|
|
err.code = "ERR_PPK_UNSUPPORTED_CIPHER";
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
if (encrypted && (passphrase == null || passphraseBytes(passphrase).length === 0)) {
|
||
|
|
const err = new Error("Encrypted PPK private key detected, but no passphrase given");
|
||
|
|
err.code = "ERR_PPK_PASSPHRASE";
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
|
||
|
|
const secret = passphraseBytes(encrypted ? passphrase : "");
|
||
|
|
let keys;
|
||
|
|
try {
|
||
|
|
keys = parsed.version === 3
|
||
|
|
? deriveV3Keys(secret, parsed.encryption, parsed.kdf)
|
||
|
|
: deriveV2Keys(secret);
|
||
|
|
} catch (err) {
|
||
|
|
if (err && err.message === "PPK_V3_ARGON2_UNAVAILABLE") {
|
||
|
|
const missing = new Error(
|
||
|
|
"Encrypted PuTTY PPK v3 keys require Argon2, which is unavailable in this runtime.",
|
||
|
|
);
|
||
|
|
missing.code = "ERR_PPK_UNSUPPORTED_KDF";
|
||
|
|
throw missing;
|
||
|
|
}
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
|
||
|
|
let privateBlob = parsed.privateBlob;
|
||
|
|
if (encrypted) {
|
||
|
|
if (privateBlob.length % AES_BLOCK !== 0) {
|
||
|
|
const err = new Error("Malformed encrypted PPK private key");
|
||
|
|
err.code = "ERR_PPK_MALFORMED";
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
privateBlob = aes256Cbc(privateBlob, keys.cipherKey, keys.iv, true);
|
||
|
|
} catch (err) {
|
||
|
|
const fail = new Error("Could not decrypt the PPK private key with the provided passphrase");
|
||
|
|
fail.code = "ERR_PPK_PASSPHRASE";
|
||
|
|
fail.cause = err;
|
||
|
|
throw fail;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const decrypted = { ...parsed, privateBlob };
|
||
|
|
if (!verifyMac(decrypted, keys)) {
|
||
|
|
const err = new Error(
|
||
|
|
encrypted
|
||
|
|
? "PPK private key integrity check failed -- bad passphrase?"
|
||
|
|
: "PPK private key integrity check failed",
|
||
|
|
);
|
||
|
|
err.code = encrypted ? "ERR_PPK_PASSPHRASE" : "ERR_PPK_MALFORMED";
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
|
||
|
|
const privateInner = privateInnerForType(parsed.type, parsed.publicBlob, privateBlob);
|
||
|
|
return {
|
||
|
|
privateKey: buildOpenSshPrivate({
|
||
|
|
publicBlob: parsed.publicBlob,
|
||
|
|
privateInner,
|
||
|
|
comment: parsed.comment,
|
||
|
|
}),
|
||
|
|
comment: parsed.comment,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Test helper: serialize an already-assembled PPK (public/private SSH blobs).
|
||
|
|
function serializePpk({
|
||
|
|
version = 2,
|
||
|
|
type,
|
||
|
|
comment = "",
|
||
|
|
publicBlob,
|
||
|
|
privateBlob,
|
||
|
|
passphrase,
|
||
|
|
argon2 = {
|
||
|
|
name: "Argon2id",
|
||
|
|
memory: 8192,
|
||
|
|
passes: 2,
|
||
|
|
parallelism: 1,
|
||
|
|
},
|
||
|
|
}) {
|
||
|
|
const encryption = passphrase ? "aes256-cbc" : "none";
|
||
|
|
const secret = passphraseBytes(passphrase || "");
|
||
|
|
let kdf;
|
||
|
|
let keys;
|
||
|
|
let storedPrivate = privateBlob;
|
||
|
|
|
||
|
|
if (version === 3) {
|
||
|
|
if (encryption === "aes256-cbc") {
|
||
|
|
kdf = {
|
||
|
|
...argon2,
|
||
|
|
saltHex: randomBytes(16).toString("hex"),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
keys = deriveV3Keys(secret, encryption, kdf);
|
||
|
|
} else {
|
||
|
|
keys = deriveV2Keys(secret);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (encryption === "aes256-cbc") {
|
||
|
|
const padLen = (AES_BLOCK - (storedPrivate.length % AES_BLOCK)) % AES_BLOCK;
|
||
|
|
if (padLen) {
|
||
|
|
storedPrivate = Buffer.concat([storedPrivate, randomBytes(padLen)]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const mac = createHmac(keys.macAlgo, keys.macKey)
|
||
|
|
.update(macPayload({ type, encryption, comment, publicBlob, privateBlob: storedPrivate }))
|
||
|
|
.digest("hex");
|
||
|
|
|
||
|
|
if (encryption === "aes256-cbc") {
|
||
|
|
storedPrivate = aes256Cbc(storedPrivate, keys.cipherKey, keys.iv, false);
|
||
|
|
}
|
||
|
|
|
||
|
|
const publicLines = wrapBase64(publicBlob);
|
||
|
|
const privateLines = wrapBase64(storedPrivate);
|
||
|
|
const lines = [
|
||
|
|
`PuTTY-User-Key-File-${version}: ${type}`,
|
||
|
|
`Encryption: ${encryption}`,
|
||
|
|
`Comment: ${comment}`,
|
||
|
|
`Public-Lines: ${publicLines.length}`,
|
||
|
|
...publicLines,
|
||
|
|
];
|
||
|
|
if (kdf) {
|
||
|
|
lines.push(
|
||
|
|
`Key-Derivation: ${kdf.name}`,
|
||
|
|
`Argon2-Memory: ${kdf.memory}`,
|
||
|
|
`Argon2-Passes: ${kdf.passes}`,
|
||
|
|
`Argon2-Parallelism: ${kdf.parallelism}`,
|
||
|
|
`Argon2-Salt: ${kdf.saltHex}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
lines.push(
|
||
|
|
`Private-Lines: ${privateLines.length}`,
|
||
|
|
...privateLines,
|
||
|
|
`Private-MAC: ${mac}`,
|
||
|
|
"",
|
||
|
|
);
|
||
|
|
return lines.join("\n");
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
isPpkPrivateKey,
|
||
|
|
convertPpkToOpenSsh,
|
||
|
|
serializePpk,
|
||
|
|
};
|