The SSH protocol in TypeScript, built from the bottom up: the data types everything is written in, the packet that carries them, the negotiation and exchange that agree a secret, the key formats people paste into a file, and the proof that somebody holds one.
These are the rules that decide who is let into a machine, and most of them are pure functions over bytes. They should be readable, and they should be checkable without a socket.
So every layer here is separable and tested on its own. The fingerprints in the
test suite are checked against what ssh-keygen -lf prints, and the signatures
are made by node:crypto over the bytes RFC 4252 specifies - not against
fixtures this library produced, which would only prove it agrees with itself.
bun add @stacksjs/ts-sshThe part a forge needs most. The same key appears in three shapes, and this
moves between them: the authorized_keys line somebody pasted, the wire blob a
signature is checked against, and the fingerprint a person compares.
import { parseAuthorizedKey, parseAuthorizedKeys, sameKey } from '@stacksjs/ts-ssh'
const key = parseAuthorizedKey('ssh-ed25519 AAAAC3Nza… ada@example.com')
// { type, blob, fingerprint: 'SHA256:…', publicKey, bits, options, comment }
parseAuthorizedKeys(await Bun.file('~/.ssh/authorized_keys').text())
sameKey(offered.blob, stored.blob) // constant time, by blobParsing is strict, because this is the file that decides who may log in. A line
whose stated type disagrees with the blob inside it is refused rather than
guessed at, and base64 is validated before it is decoded - atob accepts things
that are not base64 and quietly produces the wrong bytes.
Options come before the type and may contain spaces inside quotes, so the type
is searched for rather than split to. command="git-shell -c ssh-rsa" parses
the way you would want.
import { readUserAuthRequest, verifyUserAuthSignature } from '@stacksjs/ts-ssh'
const request = readUserAuthRequest(payload)
const proof = verifyUserAuthSignature({ sessionId, request })A signature that verifies says only whoever sent this holds that private key. Whether they may log in is a separate question, and keeping the two apart is the point.
The session identifier is the first field signed, which is what makes it safe: a signature captured from one connection cannot be replayed into another, or under a different user, or against a different service. The test suite checks each of those refusals.
import { Reader, Writer } from '@stacksjs/ts-ssh'
const blob = new Writer().string('ssh-ed25519').string(point).done()
const reader = new Reader(blob)
reader.text() // 'ssh-ed25519'
reader.string() // the pointBounds-checked everywhere. A Reader parses bytes an unauthenticated peer
chose, so a length it invented becomes a refusal rather than an out-of-bounds
read.
mpint is the type most often got wrong: zero is an empty string rather than a
zero byte, and a positive number whose top bit is set gets a leading zero so it
is not read back as negative. Both are pinned against the examples in RFC 4251.
import { deriveKey, exchangeHash, framePacket, negotiateAll, unframePacket } from '@stacksjs/ts-ssh'negotiateAll picks the client's first choice that the server also offers,
per RFC 4253 §7.1 - a server that picks its own favourite can be talked down to
its weakest option.
exchangeHash is the eight fields that make the handshake tamper-evident. A
signature over it is a signature over both sides' idea of what was negotiated,
so an attacker who edited the algorithm lists in flight produces a different
hash and a signature that does not check out.
ts-ssh keys ~/.ssh/authorized_keys # what is in it, and what could not be read
ts-ssh fingerprint "ssh-ed25519 AAAA…" # SHA256:…- Wire format - every type in RFC 4251 §5, read and written, bounds-checked
- Public keys -
authorized_keyslines, wire blobs, fingerprints; ed25519, RSA and ECDSA read, unknown types carried without pretending to understand them - Packet protocol - framing, the padding arithmetic, the version exchange and its line buffering
- Key exchange - algorithm negotiation, the exchange hash, and the six derived keys of RFC 4253 §7.2
- Public key authentication - the signed blob of RFC 4252 §7, and ed25519 verification
Being plain about it, because a half-built SSH server is worse than none:
- Ciphers.
chacha20-poly1305@openssh.comand the GCM modes are negotiated but not yet applied, so there is no encrypted transport. - The connection layer. Channels,
exec, and the window accounting. - Signature verification beyond ed25519. RSA and ECDSA keys are read and fingerprinted; a signature over one is refused rather than trusted, which is the safe direction to be incomplete in.
The layers that exist are the ones a host needs to store keys, recognise them, and check a proof. The transport is next.
bun testPlease see our releases page for more information on what has changed recently.
Please see CONTRIBUTING for details.
For help, discussion about best practices, or any other conversation that would benefit from being searchable:
For casual chit-chat with others using this package:
Join the Stacks Discord Server
“Software that is free, but hopes for a postcard.” We love receiving postcards from around the world showing where Stacks is being used! We showcase them on our website too.
Our address: Stacks.js, 12665 Village Ln #2306, Playa Vista, CA 90094, United States 🌎
We would like to extend our thanks to the following sponsors for funding Stacks development. If you are interested in becoming a sponsor, please reach out to us.
The MIT License (MIT). Please see LICENSE for more information.
Made with 💙
