modernssh

Detached SSH signatures

SSHSignature signs, parses, serializes, and verifies the detached armored signature format used by ssh-keygen -Y. The format reuses SSH public keys and signature encodings without requiring an SSH connection.

Sign and verify

Every signature belongs to a non-empty namespace. The namespace is part of the signed preimage and prevents a signature created for one application protocol from being accepted by another. Choose a stable application-specific value rather than a value derived from untrusted input.

import { readFile, writeFile } from "node:fs/promises"
import { PrivateKey, SSHSignature } from "@bunkerch/modernssh"

const message = await readFile("release.tar.gz")
const privateKey = PrivateKey.fromString(await readFile("release_signer", "utf8"))
const signature = SSHSignature.sign(message, privateKey, {
    namespace: "com.example.release",
})

await writeFile("release.tar.gz.sig", signature.toString())

Parsing accepts either armored text or the raw binary blob returned by serialize(). Verification requires the expected namespace instead of trusting the namespace stored inside an untrusted signature.

const signature = SSHSignature.parse(await readFile("release.tar.gz.sig"))
const message = await readFile("release.tar.gz")

if (!signature.verify(message, "com.example.release")) {
    throw new Error("Invalid release signature")
}

verify() proves only that the embedded public key signed the message for that namespace. It does not decide whether that key is trusted. Compare signature.publicKey with a configured key or use AllowedSigners to apply principal, certificate-authority, namespace, time, and optional revocation policy before accepting the result.

Allowed signers

AllowedSigners parses the same policy-file shape used by ssh-keygen -Y verify. Load the policy once and supply the expected identity and namespace for each verification:

import { AllowedSigners, KeyRevocationList, SSHSignature } from "@bunkerch/modernssh"

const allowed = await AllowedSigners.load("allowed_signers")
const revocations = await KeyRevocationList.load("revoked_signers.krl")
const signature = SSHSignature.parse(await readFile("release.tar.gz.sig"))

if (
    !allowed.verify(await readFile("release.tar.gz"), signature, {
        principal: "release@example.com",
        namespace: "com.example.release",
        revocations,
    })
) {
    throw new Error("Untrusted release signature")
}

Principal and namespace fields use case-sensitive * and ? patterns, with ! negation taking precedence over a positive match. Wildcards operate on UTF-8 bytes, so ? matches one encoded byte rather than one Unicode code point. Supported options are cert-authority, namespaces, valid-after, and valid-before; keywords are case-insensitive and option values are quoted. Quoted namespace lists may contain spaces. Timestamps use YYYYMMDD[Z] or YYYYMMDDHHMM[SS][Z]. A UTC suffix is also accepted. Values without a suffix use the process's local time zone, matching the command-line format.

For a cert-authority entry, the signature must embed a valid user certificate signed by that entry's key. Its certificate signature, validity interval, and exact requested principal are checked in addition to the allowed-signers patterns. An exact certificate entry without cert-authority is treated as an exact key, matching command-line behavior. Allowed-signers valid-before is inclusive, while a certificate's own validBefore instant is exclusive.

matchPrincipals(identity) returns each policy principal field that positively matches an identity. findPrincipals(signature) instead finds the principals authorized by the first currently valid entry for the signature's embedded key. Exact-key entries return their configured patterns; certificate-authority entries return matching concrete certificate principals. The lookup can take at and revocations options. Like the command-line find-principals operation, it inspects key authorization without verifying possession of the signed message and does not apply namespace restrictions. Use verify() for a security decision.

Agent-backed signing

Use signWithAgent() when private material belongs to a local, forwarded, hardware-backed, or application-defined Agent. The method resolves the selected public key, requests the signature, and cryptographically checks the agent response before returning it. The message and options are validated and copied before the first awaited agent operation. The public key returned by the agent is serialized into an owned key before signing, and the agent receives a disposable copy of the verification preimage; agent-side mutation cannot rewrite the key or bytes used for verification.

const [[id]] = await agent.getPublicKeys()
const signature = await SSHSignature.signWithAgent(message, agent, id, {
    namespace: "com.example.release",
    hashAlgorithm: "sha512",
})

Both sha256 and sha512 message hashing are supported; sha512 is the default. RSA signing uses the corresponding RSA-SHA2 signature and rejects legacy RSA-SHA1 signatures. The complete signed preimage, including its namespace and message digest, is sent to an agent—not the original message.

Encoding and limits

toString() emits the standard header and footer with 70-character base64 lines, matching ssh-keygen. serialize() returns the unarmored versioned blob. Parsed namespace and reserved buffers are exposed through defensive copies. Unsupported future versions, unsupported hashes, RSA-SHA1, non-canonical base64, trailing fields, NUL namespaces, binary blobs above 1 MiB, and armored input above 2 MiB are rejected.

Allowed-signers files are strict UTF-8 and bounded to 16 MiB with 64 KiB lines. Unknown or duplicate options, malformed patterns, invalid keys, impossible timestamps, reversed validity windows, NUL, and malformed quoting reject the complete file instead of silently weakening policy.

The parser preserves non-empty reserved bytes for forward-compatible inspection and serialization, but version 1 signing always emits the required empty reserved field. Namespace values may be strings or opaque buffers; string inputs are encoded as UTF-8.

Command-line interoperability

A signature written by the library can be verified using an allowed-signers file:

ssh-keygen -Y verify \
    -f allowed_signers \
    -I signer@example.com \
    -n com.example.release \
    -s release.tar.gz.sig < release.tar.gz

Signatures produced by ssh-keygen -Y sign can be parsed and verified through the same SSHSignature interface. Interoperability tests exercise both directions with Ed25519 and RSA keys.

On this page