442 lines
13 KiB
JavaScript
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;
|
|
}
|