All files / sshsig.ts

100.00% Branches 32/32
100.00% Functions 3/3
100.00% Lines 62/62
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
x5
x5
x5
x5
 
 
x5
x5
x5
 
 
 
 
 
 
 
x5
x16
x16
 
x16
x14
x16
 
x7
x7
x5
x1
x7
 
 
 
 
 
 
x5
x5
x5
x5
 
x16
 
x16
x16
x1
x1
x13
x15
 
x12
x12
x15
x1
x1
x11
x11
x11
 
 
x11
x15
x1
x1
x10
x15
 
 
x9
x15
x1
x1
x8
x15
x1
x1
 
 
x7
x7
x7
x7
x7
x7
 
 
x15
x2
x2
x4
x16
























































































import { ed25519 } from "@noble/curves/ed25519.js";
import { sha256, sha512 } from "@noble/hashes/sha2.js";
import { decodeBase64 } from "@std/encoding/base64";
import { concat, Reader, writeString } from "./sshwire.ts";

// SSHSIG format: see OpenSSH PROTOCOL.sshsig.
const MAGIC = new TextEncoder().encode("SSHSIG");
const enc = new TextEncoder();
const dec = new TextDecoder();

export interface SshsigResult {
  /** The signer's public-key wire blob (string "ssh-ed25519" + string pubkey). */
  publicKeyWire: Uint8Array;
}

/** Strip the `-----BEGIN/END SSH SIGNATURE-----` armor and base64-decode the body. */
function dearmor(armored: string): Uint8Array {
  const m = armored.match(
    /-----BEGIN SSH SIGNATURE-----([\s\S]*?)-----END SSH SIGNATURE-----/,
  );
  if (!m) throw new Error("not an SSH signature");
  return decodeBase64(m[1].replace(/\s+/g, ""));
}

function hashMessage(algorithm: string, message: Uint8Array): Uint8Array {
  if (algorithm === "sha512") return sha512(message);
  if (algorithm === "sha256") return sha256(message);
  throw new Error(`unsupported SSHSIG hash algorithm: ${algorithm}`);
}

/**
 * Verify an armored SSHSIG over `message` under `expectedNamespace`.
 * Returns the signer's public key on success; throws on any failure.
 * Only `ssh-ed25519` keys are supported.
 */
export function verifySshsig(
  message: Uint8Array,
  armored: string,
  expectedNamespace: string,
): SshsigResult {
  const r = new Reader(dearmor(armored));

  const magic = r.readBytes(6);
  if (!MAGIC.every((b, i) => b === magic[i])) {
    throw new Error("bad SSHSIG magic");
  }
  const version = r.readUint32();
  if (version !== 1) throw new Error(`unsupported SSHSIG version ${version}`);

  const publicKeyWire = r.readString();
  const namespace = dec.decode(r.readString());
  if (namespace !== expectedNamespace) {
    throw new Error(`namespace mismatch: ${namespace} != ${expectedNamespace}`);
  }
  r.readString(); // reserved
  const hashAlgorithm = dec.decode(r.readString());
  const signatureBlob = r.readString();

  // Public key: string "ssh-ed25519" + string(32-byte key).
  const pk = new Reader(publicKeyWire);
  if (dec.decode(pk.readString()) !== "ssh-ed25519") {
    throw new Error("unsupported SSHSIG key type");
  }
  const publicKey = pk.readString();
  if (publicKey.length !== 32) throw new Error("bad ed25519 public key length");

  // Signature: string "ssh-ed25519" + string(64-byte signature).
  const sig = new Reader(signatureBlob);
  if (dec.decode(sig.readString()) !== "ssh-ed25519") {
    throw new Error("unsupported SSHSIG signature type");
  }
  const rawSignature = sig.readString();
  if (rawSignature.length !== 64) {
    throw new Error("bad ed25519 signature length");
  }

  // The signed blob is MAGIC || namespace || reserved || hash_alg || H(message).
  const signed = concat(
    MAGIC,
    writeString(enc.encode(namespace)),
    writeString(new Uint8Array(0)),
    writeString(enc.encode(hashAlgorithm)),
    writeString(hashMessage(hashAlgorithm, message)),
  );

  if (!ed25519.verify(rawSignature, signed, publicKey)) {
    throw new Error("SSHSIG signature verification failed");
  }
  return { publicKeyWire };
}