gist/gists/ssh.js
2025-11-16 08:27:18 +00:00

442 lines
13 KiB
JavaScript

/**
* @licstart The following is the entire license notice for the JavaScript
* code in this file.
*
* Copyright (C) 2025 Arija A. <ari@ari.lt>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* @licend The above is the entire license notice for the JavaScript code
* in this file.
*/
/*
* =========================================================================================
* SSH public key parser and signature validator in pure JavaScript using browser crypto API
*
* SSH public key format: ssh-ed25519/ssh-rsa AAAA... your-email@example.com
* SSH signed message format:
* -----BEGIN SSH SIGNED MESSAGE-----
* ... content
* -----BEGIN SSH SIGNATURE-----
* U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgeLCO22sqnFTtpBjcf8Kdkb...
* ...
* -----END SSH SIGNATURE-----
* =========================================================================================
* */
"use strict";
const SSH_HASH_ALGORITHMS_MAP = {
sha1: "SHA-1",
sha256: "SHA-256",
sha384: "SHA-384",
sha512: "SHA-512",
};
function ssh_b64_to_u8a(base64) {
const bytes = atob(base64);
const length = bytes.length;
const arr = new Uint8Array(length);
for (let idx = 0; idx < length; ++idx) {
arr[idx] = bytes.charCodeAt(idx);
}
return arr;
}
function ssh_read_uint32_be(arr, offset) {
return (
((arr[offset] << 24) |
(arr[offset + 1] << 16) |
(arr[offset + 2] << 8) |
arr[offset + 3]) >>>
0
);
}
function ssh_write_uint32_be(num) {
return new Uint8Array([
(num >>> 24) & 0xff,
(num >>> 16) & 0xff,
(num >>> 8) & 0xff,
num & 0xff,
]);
}
function ssh_parse_buffer(buffer) {
let offset = 0;
function read_string() {
const len = ssh_read_uint32_be(buffer, offset);
offset += 4;
const str_bytes = buffer.slice(offset, offset + len);
offset += len;
return str_bytes;
}
return { read_string: read_string, offset_ref: () => offset };
}
function ssh_parse_key(pubkey) {
const parts = pubkey.trim().split(" ");
if (parts.length !== 3) {
throw new Error(
"Invalid SSH key format (ssh-ed25519/ssh-rsa AAAA... your-email@example.com)",
);
}
const ssh_type = parts[0];
const ssh_key_b64 = parts[1];
const ssh_contact = parts[2];
const bytes = ssh_b64_to_u8a(ssh_key_b64);
const parser = ssh_parse_buffer(bytes);
const key_type_bytes = parser.read_string();
const key_type = new TextDecoder().decode(key_type_bytes);
if (key_type !== ssh_type) {
throw new Error(
`Mismatch between declared key type and payload: ${ssh_type} vs ${key_type}`,
);
}
if (key_type === "ssh-ed25519") {
/* Next is the 32-byte key */
const key_bytes = parser.read_string();
return {
type: "ed25519",
key: { k: key_bytes },
contact: ssh_contact,
};
} else if (key_type === "ssh-rsa") {
/* Next two are exponent (e) and modulus (m) */
const e = parser.read_string();
let m = parser.read_string();
if (m.length % 2 == 1) {
m = m.slice(1); /* Remove leading 0 */
}
return {
type: "rsa",
key: {
e,
m,
b: m.length * 8,
},
contact: ssh_contact,
};
} else {
throw new Error(`Unsupported SSH key type: ${key_type}`);
}
}
function ssh_parse_signed_msg(message) {
const content_regex =
/^-----BEGIN SSH SIGNED MESSAGE-----\n(.*?)\n-----BEGIN SSH SIGNATURE-----\n/ms;
const signature_regex =
/^-----BEGIN SSH SIGNATURE-----\n(.*?)\n-----END SSH SIGNATURE-----/ms;
const content_match = message.match(content_regex);
const signature_match = message.match(signature_regex);
if (!content_match || !signature_match) {
return null;
}
return {
content: content_match[1],
signature: signature_match[1].trim().replaceAll("\n", ""),
};
}
/*
* https://datatracker.ietf.org/doc/draft-josefsson-sshsig-format/
*
* byte[6] MAGIC_PREAMBLE
* uint32 SIG_VERSION
* string publickey
* string namespace
* string reserved
* string hash_algorithm
* string signature
*/
function ssh_parse_sshsig(sshsig_b64) {
const buf = ssh_b64_to_u8a(sshsig_b64);
let offset = 0;
function read_ssh_string(buf, offset) {
const length = ssh_read_uint32_be(buf, offset);
offset += 4;
const str = buf.slice(offset, offset + length);
return { str, new_offset: offset + length };
}
/* MAGIC_PREAMBLE */
const magic = new TextDecoder().decode(buf.slice(offset, offset + 6));
offset += 6;
if (magic !== "SSHSIG") {
throw new Error(`Invalid MAGIC_PREAMBLE: ${magic}`);
}
/* SIG_VERSION */
const sig_version = ssh_read_uint32_be(buf, offset);
offset += 4;
if (sig_version !== 0x01) {
throw new Error(`Unsupported SIG_VERSION: ${sig_version}`);
}
/* publickey */
let result = read_ssh_string(buf, offset);
const publickey = result.str;
offset = result.new_offset;
/* namespace */
result = read_ssh_string(buf, offset);
const namespace = new TextDecoder().decode(result.str);
offset = result.new_offset;
/* reserved */
result = read_ssh_string(buf, offset);
const reserved = new TextDecoder().decode(result.str);
offset = result.new_offset;
/* hash_algorithm */
result = read_ssh_string(buf, offset);
const hash_algorithm = new TextDecoder().decode(result.str);
offset = result.new_offset;
/* signature (nested SSH string) */
result = read_ssh_string(buf, offset);
const signature_blob = result.str;
offset = result.new_offset;
/* parse nested signature blob: [string algorithm][string signature_bytes] */
let inner_offset = 0;
let inner_res = read_ssh_string(signature_blob, inner_offset);
const sig_algorithm = new TextDecoder().decode(inner_res.str);
inner_offset = inner_res.new_offset;
inner_res = read_ssh_string(signature_blob, inner_offset);
const signature = inner_res.str;
inner_offset = inner_res.new_offset;
return {
magic,
sig_version,
publickey,
namespace,
reserved,
hash_algorithm,
sig_algorithm,
signature,
};
}
/* https://datatracker.ietf.org/doc/draft-josefsson-sshsig-format/
*
* Signed data:
*
* byte[6] MAGIC_PREAMBLE
* string namespace
* string reserved
* string hash_algorithm
* string H(message)
*/
async function get_signed_data(parsed_sig, content) {
function encode_string(str) {
const encoder = new TextEncoder();
const str_bytes = encoder.encode(str);
const len_bytes = ssh_write_uint32_be(str_bytes.length);
const buf = new Uint8Array(4 + str_bytes.length);
buf.set(len_bytes, 0);
buf.set(str_bytes, 4);
return buf;
}
const hash_algorithm_name =
SSH_HASH_ALGORITHMS_MAP[parsed_sig.hash_algorithm.toLowerCase()];
if (!hash_algorithm_name) {
throw new Error(
`Unsupported hash algorithm: ${parsed_sig.hash_algorithm}`,
);
}
const hash_algorithm_bytes = encode_string(parsed_sig.hash_algorithm);
const content_buf = new TextEncoder().encode(content);
const hash_buffer = await crypto.subtle.digest(
hash_algorithm_name,
content_buf,
);
const hash = new Uint8Array(hash_buffer);
const hash_len_bytes = ssh_write_uint32_be(hash.length);
const magic = new TextEncoder().encode(parsed_sig.magic);
const namespace = encode_string(parsed_sig.namespace);
const reserved = encode_string(parsed_sig.reserved);
const signed_data_len =
magic.length +
namespace.length +
reserved.length +
hash_algorithm_bytes.length +
hash_len_bytes.length +
hash.length;
const signed_data = new Uint8Array(signed_data_len);
let offset = 0;
signed_data.set(magic, offset);
offset += magic.length;
signed_data.set(namespace, offset);
offset += namespace.length;
signed_data.set(reserved, offset);
offset += reserved.length;
signed_data.set(hash_algorithm_bytes, offset);
offset += hash_algorithm_bytes.length;
signed_data.set(hash_len_bytes, offset);
offset += hash_len_bytes.length;
signed_data.set(hash, offset);
return signed_data;
}
async function ssh_ed25519_verify_signature(parsed_key, parsed_sig, content) {
const crypto_key = await crypto.subtle.importKey(
"raw",
parsed_key.key.k,
{ name: "Ed25519" },
false,
["verify"],
);
const signed_data = await get_signed_data(parsed_sig, content);
const is_valid = await crypto.subtle.verify(
{ name: "Ed25519" },
crypto_key,
parsed_sig.signature,
signed_data,
);
return is_valid;
}
async function ssh_rsa_verify_signature(parsed_key, parsed_sig, content) {
/* Helper: Ensure unsigned integer as per ASN.1 DER encoding rules.
* Prepend 0x00 if the MSB of first byte is set */
function to_der_int(buf) {
if (buf[0] & 0x80) {
const extended = new Uint8Array(buf.length + 1);
extended.set([0x00], 0);
extended.set(buf, 1);
return extended;
}
return buf;
}
/* Helper: Encode ASN.1 length bytes */
function encode_length(len) {
if (len < 128) {
return Uint8Array.of(len);
}
let octets = [];
while (len > 0) {
octets.unshift(len & 0xff);
len >>= 8;
}
return Uint8Array.of(0x80 | octets.length, ...octets);
}
/* Helper: Encode ASN.1 INTEGER */
function encode_asn1_integer(buf) {
const int_buf = to_der_int(buf);
const len = encode_length(int_buf.length);
return Uint8Array.of(0x02, ...len, ...int_buf);
}
/* Helper: Encode ASN.1 SEQUENCE */
function encode_asn1_sequence(buffers) {
const total_length = buffers.reduce((acc, b) => acc + b.length, 0);
const len = encode_length(total_length);
const seq = new Uint8Array(1 + len.length + total_length);
seq[0] = 0x30;
seq.set(len, 1);
let offset = 1 + len.length;
buffers.forEach((b) => {
seq.set(b, offset);
offset += b.length;
});
return seq;
}
/* OID for rsaEncryption: 1.2.840.113549.1.1.1 */
const rsa_oid = new Uint8Array([
0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01,
]);
const null_param = new Uint8Array([0x05, 0x00]); /* NULL */
/* AlgorithmIdentifier SEQUENCE */
const alg_id = encode_asn1_sequence([rsa_oid, null_param]);
/* RSAPublicKey SEQUENCE: modulus and exponent integers */
const modulus_int = encode_asn1_integer(parsed_key.key.m);
const exponent_int = encode_asn1_integer(parsed_key.key.e);
const rsa_pubkey_seq = encode_asn1_sequence([modulus_int, exponent_int]);
/* BIT STRING wrapping the RSAPublicKey sequence */
const bit_string_len = encode_length(rsa_pubkey_seq.length + 1);
const bit_string = new Uint8Array(
1 + bit_string_len.length + 1 + rsa_pubkey_seq.length,
);
bit_string[0] = 0x03; /* BIT STRING */
bit_string.set(bit_string_len, 1);
bit_string[1 + bit_string_len.length] = 0x00; /* no padding bits */
bit_string.set(rsa_pubkey_seq, 1 + bit_string_len.length + 1);
/* Construct SubjectPublicKeyInfo SEQUENCE */
const spki = encode_asn1_sequence([alg_id, bit_string]);
/* Map hash algorithm */
const hash_name =
SSH_HASH_ALGORITHMS_MAP[parsed_sig.hash_algorithm.toLowerCase()];
if (!hash_name) {
throw new Error(
`Unsupported RSA hash algorithm: ${parsed_sig.hash_algorithm}`,
);
}
/* Import the RSA public key as SPKI format */
const crypto_key = await crypto.subtle.importKey(
"spki",
spki.buffer,
{
name: "RSASSA-PKCS1-v1_5",
hash: { name: hash_name },
},
false,
["verify"],
);
/* Construct the signed data buffer as per SSHSIG spec (same as ed25519) */
const signed_data = await get_signed_data(parsed_sig, content);
/* Verify the signature */
const is_valid = await crypto.subtle.verify(
{ name: "RSASSA-PKCS1-v1_5" },
crypto_key,
parsed_sig.signature,
signed_data,
);
return is_valid;
}