Making post-quantum visible: per-session indicators and warnings
Why GHOSTSHELL 0.2.0 shows the key exchange each SSH session actually negotiated rather than the one its policy offered, where it shows it, and what it does not show yet.
A technical paper from the GHOSTSHELL project, describing GHOSTSHELL 0.2.0. It has not been peer reviewed.
Abstract
A security policy says what a client is willing to offer; the server's choice decides what protects a session, and the client learns that choice only from its SSH library. GHOSTSHELL 0.2.0 reads the negotiated algorithms from its library after the first key exchange and after every rekey, and shows them in four places: a line in the terminal before any password prompt, a badge on the tab, a dialog listing every hop, and a count in Settings → Security beside the stored policy. This paper explains the three states it uses and how they are decided, the words of the summary and of the warning for a classical exchange, why a tab through jump hosts reports its weakest hop, and what is still not shown. Measured on 127.0.0.1, the library's report matched the server's own record for all 19 key exchange names it implements, 15 rekeys printed nothing new, and with the 0.1 library, which cannot report, the app said so instead of guessing.
1Introduction
In SSH the client lists the methods it will accept, in order, and the first one on that list that the server also supports is used [1]. A policy therefore says what was offered, not what runs. Under Quantum-safe preferred, GHOSTSHELL's default, a server without a post-quantum method gets a classical exchange, and a recording of that session can be decrypted once a large enough quantum computer exists [2]. GHOSTSHELL 0.1 printed a PQ tag based on its policy, which said nothing about the server's choice; its SSH library could not report one.
GHOSTSHELL 0.2.0 shows the negotiated result instead, and five rules shaped how:
- Truthful: never claim more than the library reported, or than the offer guarantees when it reports nothing.
- Timely: say it before the user types a password.
- Lasting: keep it visible after the terminal line has scrolled away.
- Whole: cover every hop of a jump chain, and name the weakest.
- Quiet: say it again only when something changed.
OpenSSH reached a similar view: from 10.1 its client warns when a connection's key agreement is not post-quantum, and from 10.6 its server logs such clients [3]. Post-quantum by default describes the policies and the SSH library; this paper is about what the user sees.
2Background
2.1What a session's protection depends on
After the key exchange, everything in a session, the login included, is encrypted with keys derived from its shared secret [1]. The key exchange method decides whether a recording can be decrypted later: a hybrid such as mlkem768x25519-sha256 [4] or sntrup761x25519-sha512 [5] is designed to stay secure while either of its halves does [2]. The host key decides who the server is, and only at connection time; the cipher and MAC protect the traffic itself, and a 256-bit cipher is not the weak point [2]. A useful report therefore names the key exchange first, then the host key, then the cipher, MAC and compression.
2.2Where the facts come from
The client learns the server's choice inside its SSH library. GHOSTSHELL's library, a fork of russh, keeps a record of what each completed key exchange negotiated: the method, the host key algorithm (a certificate type when the server presented a certificate), the cipher, the MAC in each direction, shown as <implicit> next to an AEAD cipher as OpenSSH prints it, and the compression in each direction. For the first exchange, the library stores the record only after the host key check has accepted the server, and before connect() returns; each rekey replaces it. The binding offers it as negotiatedAlgorithms(), null before the first exchange, and as negotiatedAlgorithms$, which emits after each one. The library also refuses a host key of any type but the negotiated one, so the host key named is the type that signed the handshake; that refusal was reviewed in the code but not exercised.
3Design
3.1Three states, decided conservatively
Each connection gets one of three states (Table 1). With a report, the state is the report's. Without one, the offer alone decides, and only where it leaves no doubt: if every method offered was post-quantum, whatever ran was post-quantum; if none was, the exchange was classical; otherwise the state is unknown. A connection is never shown as post-quantum because a post-quantum method came first in its offer. A method counts as post-quantum if the policy names it or if its name starts with a post-quantum family's (mlkem, sntrup, ntruprime and others), so a method a later library adds is not mistaken for classical.
| Report | Offer | State | Badge |
|---|---|---|---|
| A post-quantum method | Any | Post-quantum | Post-quantum |
| A classical method | Any | Classical | Classical key exchange |
| None | Only post-quantum methods | Post-quantum | Post-quantum |
| None | No post-quantum method | Classical | Classical key exchange |
| None | Both kinds | Unknown | Key exchange not reported |
3.2The words
The summary names the state, the method and the host key in one line. A classical summary is followed by a warning in two parts (Figure 1). The first is the risk, the same every time: traffic recorded from this connection could be decrypted later by a quantum computer. The second is the reason, which decides who can fix it. Either the server supports none of the post-quantum methods GHOSTSHELL offered, which it lists, with the OpenSSH versions that offer one by default and a note that crypto policies, other servers and network equipment vary; or the profile's own policy offered none, with a pointer to the profile's Ciphers tab. The first reason sends the user to the server's administrator, the second to their own settings. An unknown state gets one sentence saying what would have made the exchange post-quantum. Algorithms that only Legacy or a profile's own ticks allow, such as SHA-1 key exchange, CBC, ssh-rsa or HMAC-SHA1 beside a non-AEAD cipher, get a line of their own.
PQ Post-quantum key exchange: mlkem768x25519-sha256 · host key ssh-mldsa44-ed25519
! Classical key exchange: curve25519-sha256 · host key ssh-ed25519
! Not post-quantum: traffic recorded from this connection could be decrypted
later by a quantum computer. The server supports none of the post-quantum
methods GHOSTSHELL offered (mlkem768x25519-sha256, mlkem1024nistp384-sha384,
mlkem768nistp256-sha256, sntrup761x25519-sha512,
sntrup761x25519-sha512@openssh.com). OpenSSH servers offer one by default
from version 8.9 (sntrup761) and 9.9 (ML-KEM), unless the system's crypto
policy removes it; other SSH servers and network equipment vary.
? Key exchange not reported by this SSH library: post-quantum methods were
offered first, but a classical one may have been used · host key ssh-ed25519
? The key exchange is post-quantum only if the server supports one of
mlkem768x25519-sha256.
3.3Where it shows, and when
Figure 2 places each surface in the life of a connection, and Table 2 lists them. The terminal line comes at the one moment the user is sure to be looking: after the key exchange and before authentication, so no password goes into a classical session without a warning on the screen. It stays in the scrollback. The badge keeps the answer in view for the life of the tab, on the terminal toolbar, which is pinned by default. The dialog holds what no line can: every hop, every algorithm and where the policy came from. Settings → Security puts the stored policy and the open tabs on separate rows, so the page never implies that a setting is in force where a server overruled it; Settings → SSH answers a different question, what this build can do.
| Where | What it shows | When |
|---|---|---|
| Terminal | The summary; the warning when the state is not post-quantum; older algorithms in use; the profile whose policy a jump host took; the proxy-command notice | After the first key exchange, before authentication; again only when a rekey changes the record |
| Tab badge | The state as in Table 1, or the chain's case as in Table 3; a tooltip with each hop's summary | While the tab is connected |
| Connection security… | For every hop: policy, summary, warning, server authentication, cipher, MAC, compression, older algorithms, the post-quantum methods offered, where the policy came from | From the badge or the tab's menu |
| Settings → Security | "SSH policy", the stored setting, beside "SSH tabs open now", which counts open tabs by their weakest hop: post-quantum, classical or not reported | Whenever the page is open |
| Settings → SSH | The post-quantum methods and host keys this build implements, and whether its library reports negotiated algorithms | Always |
3.4Jump hosts: the weakest hop
A tab that goes through jump hosts has one connection per hop, and each negotiates on its own. The tab's state is its weakest hop's: classical if any hop is classical, else unknown if any hop did not report, else post-quantum. The badge names the case rather than averaging it away (Table 3). A post-quantum target behind a classical bastion reads Classical jump host, because the outer connection carries the bastion login and the target's address. A post-quantum chain through a proxy command reads Post-quantum inside proxy, in grey, because GHOSTSHELL cannot see the proxy's own connection. The tooltip lists each hop's summary, and the terminal names the profile whose policy a jump host was raised to.
| Target | Jump hosts and proxy | Badge |
|---|---|---|
| Post-quantum | All post-quantum, no proxy command | Post-quantum |
| Post-quantum | At least one classical | Classical jump host |
| Post-quantum | One not reported, none classical | Jump host not reported |
| Post-quantum | All post-quantum, through a proxy command | Post-quantum inside proxy |
| Classical or not reported | Any | The target's own badge (Table 1) |
3.5Shared connections and rekeys
Two tabs can share one connection. The second tab never saw the first one's lines, so it prints the shared connection's summary and warning itself, after "Sharing the open connection to". A rekey is a new key exchange inside the session, and the server could choose differently. After each one the library emits an event, and the app reads the record again and prints the summary only if the record changed, so a long session's routine rekeys add nothing to the terminal while a change of method would be announced. The badge and the dialog always show the latest record.
4Implementation
connectionSecurity.ts is a pure module with no native imports, unit-tested on its own. It reads negotiatedAlgorithms() by duck typing: a library without the method, a call that throws or rejects, and an answer of the wrong shape all mean "not reported", never an error, and a missing field reads as empty. It then assesses the state and writes every word the surfaces show. session/ssh.ts calls it right after connect() and before authentication, prints the result, and subscribes to negotiatedAlgorithms$ once authenticated. sshTab.component.ts derives the weakest hop and the badge from each hop's record, the dialog lists every hop's details, Settings → Security counts the open tabs' weakest hops, and Settings → SSH asks the library whether it can report at all. The host key in a summary comes from the record or, without one, from the type the host key check saw.
5Evaluation
5.1Method
The SSH library the app installed (russh 0.2.0-ghostshell.1) connected over 127.0.0.1 to the project's test server, which runs the same russh fork and answers an exec request with its own record of what it negotiated. The host keys (ML-DSA-44 + Ed25519, and Ed25519) were throwaway keys made in a temporary folder, and the password a random throwaway. What the library reported went through the app's own connectionSecurity.ts, as the app passes it. Four checks ran on 12 November 2026, on Windows 11 x64 with Node 24.22.2:
- Every key exchange name the library implements, 5 post-quantum and 14 classical (sntrup761 and curve25519 each have two), one connection each offering only that name: was the record there when
connect()returned, did it match the server's record in method, host key, cipher, MACs and compression, was the state right, and was the record the same after password authentication? - Three server-initiated rekeys for each of the five post-quantum names: did each event arrive, and how many lines would the app's rule print?
- A two-hop chain, the inner session reaching a second server through a forwarded channel of the first, with a classical and then a post-quantum outer hop.
- The 0.1 library (npm
russh0.1.38), which cannot report, under three policies against the same server, whose record says what really ran.
The badge for each chain follows the rule of sshTab.component.ts, applied to the measured states; the component itself was not run.
5.2Results
| Check | Result |
|---|---|
1. Record present when connect() returned | 19 of 19 names |
| 1. Record equal to the server's | 19 of 19 |
| 1. State | Right for 19 of 19: 5 post-quantum, 14 classical |
| 1. Record unchanged by authentication | 19 of 19 |
| 1. Older algorithms line | For the 3 SHA-1 key exchanges, and no other method |
| 2. Rekeys | 15 of 15 events; 0 lines printed again; median 5.8 to 33.2 ms per name from the request to the event |
| 3. Classical outer hop, post-quantum inner | Weakest hop classical: Classical jump host |
| 3. Post-quantum outer and inner hops | Post-quantum |
| 4. 0.1 library, Quantum-safe preferred | Key exchange not reported; the server ran mlkem768x25519-sha256 |
| 4. 0.1 library, Quantum-safe only | Post-quantum, the only kind offered; the server ran mlkem768x25519-sha256 |
4. 0.1 library, curve25519-sha256 only | Classical key exchange; the server ran curve25519-sha256 |
| 4. Can the library report? (the check Settings → SSH makes) | 0.2.0 library: yes; 0.1 library: no |
| App unit suite | 245 tests: 244 pass, 1 skipped (pure ML-KEM-1024, not built) |
The first 0.1 row is the case the conservative rule exists for: under the default policy the server did run a post-quantum method, and GHOSTSHELL still said it could not tell, because nothing it had could tell it. The rekey times include the exec request that started each rekey, so they bound how soon a changed record would reach the screen. One cost shows in Figure 1: the classical warning is 487 characters long, with five method names, and with its summary it fills 8 rows of an 80-column terminal. It appears only on classical connections, but it is long.
6What is not shown, and other limits
- The screens themselves were not inspected. The states and words were checked through the app's modules, its unit tests and the runs above; the templates that place them, the badge, the dialog and the settings rows, were read but not looked at in the running app.
- The trust prompt for a new host cannot show the key exchange, because the first record exists only after the host key is accepted. The decision to trust a new server comes before the line that says whether its session is post-quantum.
- Other parts of the window show nothing. The HUD, the session tree and the host bar do not show the state, although the tab's state and records are exported for them. With the toolbar unpinned, the badge appears only on hover.
- There is no history. Closed sessions leave no record of what they negotiated, nothing can be exported for an inventory, and Settings → Security counts only open tabs.
- The warning cannot be acknowledged per host, so a team with many classical devices sees it on every connection; OpenSSH lets its users turn its own warning off with the
WarnWeakCryptooption [3]. - HTTPS shows its configuration, not its connections. The Node row of Settings → Security reads the TLS groups in force, not what each HTTPS connection negotiated; with the groups the packaged app logs, it reads "Post-quantum first: X25519MLKEM768, MLKEM1024; classical fallback · TLS 1.2+". The Chromium row is a fixed statement of Chromium's default.
- Other programs' connections are out of sight. RDP runs in Windows' own client and a proxy command in its own program; Telnet and raw sockets are warned as unencrypted.
- One chain case is understated. With a library that cannot report, a target that is not reported behind a classical jump host shows Key exchange not reported rather than naming the classical hop. The 0.2.0 library reported every connection in Section 5, so the case does not arise with it.
- The new messages are in English only, and the badge's accessible name, its label followed by "Connection security details", has not been tried with a screen reader.
7Conclusion
A policy is a promise about the offer; only the server's choice is evidence about the session. GHOSTSHELL 0.2.0 takes that evidence from its SSH library after every key exchange, states it in the terminal before any password is typed, keeps it on the tab, adds every hop in one dialog, and sets the open tabs beside the stored policy in Settings. When the library cannot say, GHOSTSHELL says that, rather than counting an offer as a result. What remains is mostly reach: the parts of the window outside the tab, a record of sessions that have closed, and the connections other programs make.
References
- [1]T. Ylonen and C. Lonvick (Ed.). The Secure Shell (SSH) Transport Layer Protocol. RFC 4253, January 2006.
- [2]D. Moody, R. Perlner, A. Regenscheid, A. Robinson and D. Cooper. Transition to Post-Quantum Cryptography Standards. NIST Internal Report 8547, final, October 2026. doi:10.6028/NIST.IR.8547.
- [3]OpenSSH project. OpenSSH release notes: 10.1 (6 October 2025) and 10.6 (6 October 2026).
- [4]P. Kampanakis, D. Stebila and T. Hansen. Post-Quantum/Traditional Hybrid Key Exchange with the Module-Lattice-Based Key-Encapsulation Mechanism for Use in SSH. RFC 10042, August 2026.
- [5]M. Friedl, J. Mojzis and S. Josefsson. Secure Shell (SSH) Key Exchange Method Using Hybrid Streamlined NTRU Prime sntrup761 and X25519 with SHA-512: sntrup761x25519-sha512. RFC 9941, April 2026.