modernssh

Public-key management subsystem

RFC 4819 defines the publickey SSH subsystem for managing an authenticated user's authorized keys. RFC 7076 extends it with namespaces and certificates. This is separate from public-key authentication and agent forwarding: it changes server-side authorization data after the SSH connection has authenticated.

Support must be enabled by the server. A client API does not imply that a particular SSH daemon provides this subsystem.

Client

Open the subsystem after connecting, then use its Promise-only operations:

import { readFile } from "node:fs/promises"
import { Client, PublicKey } from "@bunkerch/modernssh"

const client = new Client({ hostname: "keys.example.com", username: "alice" })
// Configure host-key verification and authentication before connecting.
await client.connect()

const publicKeys = await client.publicKeySubsystem({ requestTimeout: 30_000 })
const key = PublicKey.parseString(await readFile("./id_ed25519.pub", "utf8"))

console.log(await publicKeys.listAttributes())
console.log(await publicKeys.listNamespaces())

await publicKeys.add(key, {
    namespace: "users",
    overwrite: false,
    attributes: [{ name: "comment", value: "alice's workstation" }],
})

for (const entry of await publicKeys.list({ namespace: "users" })) {
    console.log(entry.key.toString(), entry.attributes)
}

await publicKeys.remove(key, { namespace: "users" })
await publicKeys.close()

add(), remove(), and list() use the optional namespace and attributes request options. The client copies keys, certificates, and attributes before queueing them. String values are strict UTF-8; use a Buffer for an opaque extension value. overwrite defaults to false. When version 3 is negotiated and no namespace is supplied, RFC 7076 defines "ssh" as the default; listed key entries expose that effective namespace through entry.namespace.

RFC 7076 certificate blobs are intentionally opaque. The format name identifies how the application should parse and validate the bytes:

const certificate = await readFile("./alice-certificate.bin")

await publicKeys.addCertificate("x509v3-ssh-rsa", certificate, {
    namespace: "users",
    overwrite: false,
})

for (const entry of await publicKeys.listCertificates()) {
    console.log(entry.format, entry.namespace, entry.certificate)
}

await publicKeys.removeCertificate("x509v3-ssh-rsa", certificate, {
    namespace: "users",
})

Certificate operations and listNamespaces() require negotiated version 3. The client reports a clear error after a version-2 downgrade instead of sending an unsupported request. Namespace is required for certificate add/remove, must be valid UTF-8, and is limited to 300 Unicode characters.

Every unsuccessful status rejects with PublicKeySubsystemStatusError. Its code can be compared with PublicKeySubsystemStatusCode; message and languageTag preserve the server response:

import { PublicKeySubsystemStatusCode, PublicKeySubsystemStatusError } from "@bunkerch/modernssh"

try {
    await publicKeys.remove(key, { namespace: "users" })
} catch (error) {
    if (
        error instanceof PublicKeySubsystemStatusError &&
        error.code === PublicKeySubsystemStatusCode.KeyNotFound
    ) {
        console.log("The key was already absent")
    } else {
        throw error
    }
}

Unknown and private status values are preserved as unsigned 32-bit codes. The RFC 7076-specific codes are CertificateNotFound (192), CertificateNotSupported (193), CertificateAlreadyPresent (194), ActionNotAuthorized (195), and CannotCreateNamespace (196).

Only one request may be unacknowledged, so concurrent calls are queued in call order. await publicKeys.close() rejects pending and queued work, closes the subsystem channel, and settles after its terminal close event. Concurrent calls share one Promise; await using invokes the same operation through Symbol.asyncDispose. The request timeout also bounds shutdown, and an unresponsive peer causes only this channel to be destroyed. end() remains available for an intentional write-side EOF, while destroy(error?) aborts immediately. Operations reject on a timeout, channel close, SSH disconnect, or transport failure.

requestTimeout bounds negotiation and every serialized request. It defaults to the connection's replyTimeout, or 30 seconds when using PublicKeySubsystemClient.connect() directly. A timeout closes only the subsystem channel so an untagged late response cannot satisfy a later operation. The value must be an integer from 1 through 2147483647.

Server

The library does not choose a database, edit authorized_keys, parse certificate formats, or infer authorization rules. The application must scope storage and policy to the authenticated user and namespace, validate certificate contents, and make each mutation durable before approving it.

Access remains denied until ordinary session-channel and subsystem Hooker policies approve it. The publicKey event is observational and its listener remains synchronous; policy and storage work belongs in awaited Hooker handlers:

import { PublicKeySubsystemStatusCode, SessionChannel } from "@bunkerch/modernssh"

server.hooker.hook("channelOpenRequest", (_hook, channel, decision) => {
    decision.allowOpen = channel instanceof SessionChannel
})

server.on("connection", (connection) => {
    connection.on("channel", (channel) => {
        if (!(channel instanceof SessionChannel)) return

        channel.hooker.hook("subsystemRequest", (_hook, context, decision) => {
            if (context.subsystem !== "publickey") return
            decision.success = true
            decision.publicKey = {
                attributes: [{ name: "comment", compulsory: false }],
            }
        })

        channel.events.on("publicKey", (publicKeys) => {
            publicKeys.hooker.hook("add", async (_hook, context, controller, operation) => {
                await storeAuthorizedKey(
                    connection.username,
                    context.namespace,
                    context,
                    operation.signal,
                )
                controller.success = true
            })

            publicKeys.hooker.hook("remove", async (_hook, context, controller, operation) => {
                const removed = await removeAuthorizedKey(
                    connection.username,
                    context.namespace,
                    context.key,
                    operation.signal,
                )
                controller.success = removed
                controller.failureCode = PublicKeySubsystemStatusCode.KeyNotFound
            })

            publicKeys.hooker.hook("list", async (_hook, controller, context, operation) => {
                controller.keys = await listAuthorizedKeys(
                    connection.username,
                    context.namespace,
                    operation.signal,
                )
                controller.success = true
            })

            publicKeys.hooker.hook(
                "addCertificate",
                async (_hook, context, controller, operation) => {
                    await validateAndStoreCertificate(
                        connection.username,
                        context,
                        operation.signal,
                    )
                    controller.success = true
                },
            )

            publicKeys.hooker.hook(
                "removeCertificate",
                async (_hook, context, controller, operation) => {
                    controller.success = await removeCertificate(
                        connection.username,
                        context,
                        operation.signal,
                    )
                    controller.failureCode = PublicKeySubsystemStatusCode.CertificateNotFound
                },
            )

            publicKeys.hooker.hook("listCertificates", async (_hook, controller, operation) => {
                controller.certificates = await listCertificates(
                    connection.username,
                    operation.signal,
                )
                controller.success = true
            })

            publicKeys.hooker.hook("listNamespaces", async (_hook, controller, operation) => {
                controller.namespaces = await listNamespaces(connection.username, operation.signal)
                controller.success = true
            })
        })
    })
})

If a mutation Hooker has no handler, the server returns ActionNotAuthorized for version 3 and an appropriate version-2 failure for a downgraded peer. Missing list handlers report the request as unsupported. Hook rejections are contained by Hooker and become GeneralFailure, never an unhandled EventEmitter rejection.

PublicKeySubsystemServerOptions.requestTimeout bounds the initial version write and each request from the start of its awaited Hooker policy through completion of its final response write. It defaults to 30 seconds and must be an integer from 1 through 2147483647. Expiry aborts only that subsystem channel and clears its pending request; the authenticated SSH connection and its other multiplexed channels remain usable. Every policy Hooker receives a final PublicKeySubsystemServerOperationContext; pass its signal to cancellable authorization, storage, and hardware operations. The signal aborts with the request failure when its deadline expires or the subsystem channel closes. Cancellation cannot undo an external mutation that already completed, so backing stores should honor the signal before committing state.

Call await publicKeys.close() when the application owns the server session lifecycle. It stops request dispatch immediately, asks the SSH channel to close, and settles after the channel's terminal close event. Concurrent calls share one Promise, and Symbol.asyncDispose provides the same operation for await using. PublicKeySubsystemServerOptions.closeTimeout bounds peer acknowledgement and defaults to 30 seconds, with the same integer range; expiry force-aborts the channel and rejects the close operation. publicKeys.destroy(error?) remains the immediate abort path.

The server automatically advertises the standard namespace capability. Additional advertised attributes are configured through decision.publicKey.attributes. A critical attribute absent from that list is rejected before application policy runs. Advertising an attribute means the application understands and enforces its meaning; retaining unknown bytes is not sufficient.

The standard comment value is strict UTF-8. comment-language must immediately follow its comment and contain a valid language tag. Other attributes may restrict commands, shells, subsystems, X11, agents, environment requests, source hosts, and forwarding. Those restrictions remain application policy. A compulsory capability promises that administration applies it to every key, and an overwrite must not remove administrator-owned restrictions.

Negotiation, limits, and interoperability

Both sides advertise version 3 and negotiate the lower version. Version 2 retains the exact RFC 4819 key request layouts; version 3 uses RFC 7076 layouts and enables namespaces and certificates. A peer below version 2 receives VersionNotSupported before the channel closes. Unknown requests receive RequestNotSupported; duplicate versions, contradictory layouts, and pipelined requests are fatal protocol errors.

Frames are bounded to 256 KiB before allocation. List operations collect at most 1024 packets and 4 MiB of encoded responses. Key blobs must parse and match their outer algorithm name. Malformed framing, invalid text, repeated namespace attributes, unexpected responses, and replies without a pending request close the subsystem and reject pending work. EOF in a partial frame is also fatal. The stream parser accepts arbitrary fragmentation while allocating each declared frame once, so one-byte fragments require work linear in the bounded packet size rather than repeatedly copying the accumulated prefix.

Transport rekeying preserves an active subsystem. The root package exports the packet codec for applications that already have an authenticated SSH subsystem stream, though the high-level client and server APIs are normally preferable.

Literal byte vectors independently cover all version-2 and version-3 packet layouts. The encrypted integration suite covers awaited policy, namespaced keys, certificates, namespace listing, downgrade behavior, bounds, failures, and rekey. A pinned independent Python peer exercises both roles at version 3, including fragmented version exchange, coalesced multi-packet responses, key and certificate state transitions, and namespace listing.

On this page