Created
November 16, 2025 08:26
-
-
Save ar1ja/b0f3f6aa32f948dc156dc0688cacbe05 to your computer and use it in GitHub Desktop.
SSH public key parser and signature validator in pure JavaScript using browser crypto API
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * @licstart The following is the entire license notice for the JavaScript | |
| * code in this file. | |
| * | |
| * Copyright (C) 2025 Arija A. <[email protected]> | |
| * | |
| * 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... [email protected] | |
| * 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... [email protected])", | |
| ); | |
| } | |
| 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; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment