Question
No browser that runs on System 7 can open an https:// URL against a server configured today: Netscape 3 and 4, iCab 2.9 and MacWeb speak SSL 2/3 with RC4 and DES, without SNI and without SHA-2 certificates, and every current server closes the handshake. The usual remedy is to terminate TLS on a proxy and hand the guest plain HTTP. This article asks the narrower question: can the guest itself complete a modern handshake, and at what cost in seconds?
The answer, measured on 2026-08-30 against a public server, is yes — 1.6 s for a TLS 1.3 handshake on an emulated 68030, with a client that is not yet secure but is already correct on the wire.
Apparatus
| Layer | Choice | Why |
|---|---|---|
| TLS library | Crypto Ancienne (cryanc), C89, TLS 1.2/1.3 | Socket-agnostic (tls_consume_stream / tls_get_write_buffer), so it can sit on any Mac transport |
| Compiler | Retro68 (m68k gcc 16.1), built natively on the host in ~50 min | MPW SC has no long long; cryanc needs 64-bit arithmetic in tommath, SHA-512, GCM and Poly1305 |
| Socket layer | MacTCP driver API (PBControl on .IPP) | Works natively and through Open Transport’s MacTCP compatibility; the header is a 60-line TCP-only rewrite because Retro68’s Multiversal interfaces ship neither OT nor MacTCP |
| Guest | System 7.6.1 on Basilisk II, 68030 model, no FPU, slirp networking | The AppleBridge development machine |
| Delivery | AppleBridge: mac_put_file (MacBinary), launch_app, mac_read_file | Compile on the host, run on the guest, read the log back — no MPW involved |
The test program ttls opens a TCP connection, drives cryanc through the handshake, sends GET /, and writes every step with a TickCount timestamp (60 ticks/s) to a log file. Certificate validation is disabled and cryanc’s own PRNG is used unseeded — this is a speed measurement, not a secure client.
Measurements
| Handshake | Peer | Ticks | Seconds |
|---|---|---|---|
| TLS 1.3, X25519 | local openssl s_server | 17 | 0.28 |
| TLS 1.3, secp256r1 | local openssl s_server | 39 | 0.65 |
| TLS 1.2, ECDHE-RSA-AES128-GCM-SHA256, 5 KB chain | pit.390er.de | 122 | 2.0 |
| TLS 1.3, secp384r1, TLS_AES_128_GCM_SHA256 | pit.390er.de | 97 | 1.6 |
TCP connect took 7–10 ticks in every run. After the TLS 1.3 handshake the first decrypted application byte arrived 14 ticks after the request; the 1018-byte HTTP 401 page from the server’s authentication layer arrived intact. cryanc’s published reference is 22 s per TLS 1.2 handshake on a Macintosh IIci at 25 MHz; Basilisk II with its JIT is roughly fifteen times that, so real hardware should be expected in the 10–30 s range.
What the server demanded
The first attempt against the public server failed after the client hello: 67 bytes back, then a 7-byte fatal alert. The 67-byte record was a ServerHello whose random began CF 21 AD 74 E5 9A 61 11 BE — the fixed value that marks a HelloRetryRequest. The server was asking for a key share on a group the client had not offered.
Probing the server with OpenSSL 3.6 settled which group: it accepts only secp384r1 for TLS 1.3 key exchange. X25519, secp256r1, X448 and X25519MLKEM768 are all answered with handshake_failure. cryanc lists secp384r1 in supported_groups but only ever sends a secp256r1 (or, with TLS_CURVE25519, an X25519) key share, and its client has no HelloRetryRequest handling at all — the only HRR code in the library is on the server side. It parsed the HRR as a ServerHello, derived keys from nothing, and the next record it sent drew unexpected_message.
TLS 1.2 succeeded against the same server on the first try, because there the server chooses the curve in ServerKeyExchange before the client commits to one.
Two library defects surfaced by a real endpoint
TLS_CURVE25519client hello length. With X25519 enabled, the precomputed extensions length is 70 where the emitted bytes need 76 (a 42-byte key_share instead of 75). OpenSSL rejects the hello withtls_process_client_hello: length mismatch. A one-line fix.- No client-side HelloRetryRequest. Patched around for this spike with a
TLS_KEYSHARE_P384build switch that sends a secp384r1 share (97-byte point, extensions length 141); the receive side already handled any curve generically. The correct fix is HRR support: on the retry, re-issue the hello with the requested group and restart the transcript with themessage_hashconstruction.
Both patches are local and unsent; both are upstream-worthy.
Four traps on the way
- Retro68’s console library dies silently on this guest. Its own HelloWorld sample launches and vanishes; a plain-window app (the Dialog sample) runs. The test program draws its own window and logs to a file.
- A background-launched application paints nothing unless it services
updateEvt. Three rounds of “no window, therefore did not run” — while the log file on disk showed that it had run to completion. The artefact was the evidence; the screen was not. - Case-insensitive file systems and newlib.
<string.h>includes<strings.h>, which on the host’s disk resolves to the Multiversal ToolboxStrings.hand drags the entire Toolbox into a crypto library (anenumnamedmaskcollides). A two-linestrings.hshim ahead of the include path is the fix. - Retro68’s default
SIZEresource is 1 MB, and Multiversal has noGetApplLimit— read the low-memory global at$130.
Addendum, 2026-08-30 afternoon: HelloRetryRequest implemented
The “correct fix” named above has been made, and it changed one reading in this article. Client-side HelloRetryRequest support now exists in the patched cryanc: tls_parse_hello recognises the HRR random, records the requested group and any cookie, replaces the transcript with message_hash(ClientHello1), resets the handshake state so the real ServerHello is accepted, and reuses the library’s existing “send hello” path to emit a second ClientHello with the same random and a key share on the requested group. The server’s compatibility-mode ChangeCipherSpec that follows an HRR is ignored on the client, and secp521r1 key shares are accepted on receive — that case was a bare break.
The last point is where the reading changed. Offered the full list secp256r1, secp384r1, secp521r1, the server asks for secp521r1, not P-384: the earlier “P-384 only” finding came from a probe that offered a single group at a time, and server preference decides when several are acceptable. With the retry in place the guest completes the exchange the server actually wants:
| Step | Ticks |
|---|---|
| TCP connect | 10 |
| ClientHello 1 (P-256 share) built | 12 |
| HelloRetryRequest consumed, ClientHello 2 (P-521 share) sent | 94 |
| ServerHello + certificate flight (5167 bytes) consumed, handshake complete | 190 |
| First decrypted application byte after the request | 16 |
| Whole session | 226 |

The test program’s window on the emulated Mac after the run above: both server records with their first bytes (the HRR random cf 21 ad 74 …, then the real ServerHello), the negotiated cipher, and the decrypted response headers.
190 ticks = 3.2 s for a TLS 1.3 handshake that includes a retry and two key generations, the second on P-521 — twice the 1.6 s of the forced-P-384 run, and still within what a person waits for. The same library built natively on the host reaches the same server in 0.7 s with the 1.2 fallback disabled (carl -3), which is the check that the retry path, not a silent downgrade, is what succeeds: carl retries as TLS 1.2 by default, so a plain “it works” from it proves nothing about 1.3.
The whole change is 166 lines in cryanc.c, kept as cryanc-client-hrr.patch beside the test program, and is the candidate for an upstream submission.
Addendum, 2026-08-30 evening: the certificate was never checked — now it is
Every handshake above accepted whatever certificate the server presented; the test program passed NULL as its validation callback, and this addendum was meant to be the short note that turns that on. It became something else. cryanc ships tls_default_verify (validity dates, chain signatures, subject against SNI, root match) and tls_load_root_certificates; with a 13-root bundle exported from the host’s keychain, the handshake to pit.390er.de still succeeded — and so did one to a local openssl s_server with a self-signed certificate. A verifier that never rejects has not been shown to exist.
The cause is an off-by-one in the TLS 1.3 certificate parser. The Certificate message begins with a certificate_request_context byte that lies inside the handshake length; tls_parse_certificate consumed that byte before comparing the length against the buffer, so the comparison failed by one, the function returned TLS_NEED_MORE_DATA, the caller took that as benign, and the client went on with zero certificates — which also meant the verification callback was skipped “for lack of certificates”. TLS 1.2, whose message has no such byte, verified correctly all along. A second latent defect in the same loop: the per-entry extensions of TLS 1.3 were never stepped over properly. Both are fixed and submitted as classilla/cryanc#25, separate from the HelloRetryRequest work in #24.
What proves a verifier is what it rejects, so the guest ran a matrix rather than a demo:
| Case | Bundle | Peer | SNI | Result |
|---|---|---|---|---|
| untrusted root | 13 public roots | local self-signed | matches CN | rejected, -13, after 58 ticks of root search |
| wrong name | 13 roots + the local cert | local self-signed | other.test | rejected on the subject check alone |
| trusted, right name | 13 roots + the local cert | local self-signed | matches CN | HTTP 200, handshake 138 ticks |
| public chain | 13 public roots | pit.390er.de | pit.390er.de | verified: chain RSA-4096/2048/4096, subject, root ISRG X1 — 332 ticks = 5.5 s |
A wrong name against the public server proves nothing, incidentally: the server answers unrecognized_name before any certificate is sent.
The 5.5 s is the honest number for this machine: the retry to P-521 from the previous addendum plus three RSA signature checks, two of them 4096-bit, plus the search through thirteen roots. The unverified handshake was 3.2 s; verification costs about 1.8 s on an emulated 68030.
Entropy. cryanc keys its arc4 pool from the clock alone on systems without an OS RNG. The patch adds an opt-in TLS_ENTROPY_HOOK; the 68K program feeds it 24 tick-boundary iteration counts (interrupt and, under emulation, host-scheduling jitter — 0.4 s), the mouse position, the clock, and stack and heap addresses. That is weak against an observer on the same machine and it is not a proof of randomness; it is the difference between a pool an attacker can reconstruct from the date and one they cannot.
Addendum, 2026-08-30, later: the guest fetches a page of its own
The client is now a tool rather than a measurement. mac_https_get(url) on the AppleBridge side resolves the name, writes a request file with a nonce into the guest, launches httpsget — the verified client above, reading the request and streaming the response to a file — and polls the guest’s log until it carries that nonce, which it only does once the run is complete. The first thing the emulated Macintosh fetched with it was this article: 27 KB, HTTP 200, chain verified, 273 ticks for the handshake and 6.3 s for the whole call. The classic Mac did the cryptography; the host moved the files. Source and the two library patches are in mac/httpsget/ of the AppleBridge repository (PR #257).
Addendum, 2026-08-30, evening: a browser, with nothing on the host
The question this article opened with — is there an https:// browser for System 7 — now has a second answer besides “no”. WebPeek 2 (examples/webpeek/retro68/ in the AppleBridge repository) is the client above behind a location line: the Macintosh resolves the name itself with a DNS query over UDP, negotiates TLS 1.3 and verifies the chain, reduces the HTML to text as it arrives, and lists the page’s first two dozen links in a menu. The host does nothing; the gateway that WebPeek 1 depended on is gone.

The section index of this site in WebPeek 2. The header line is the measurement: DNS 7 ticks, connect 9, TLS handshake 238, 323 ticks in all — 5.4 s from Return to page on an emulated 68030, certificate chain verified. A second page chosen from the Links menu took 289 ticks; the resolver in slirp had cached the name.
It is a reader, not a renderer: text in TextEdit’s 28 KB, no images, no forms, no scripts — which is also why a 2026 page survives in it at all. The library patches that made it possible are the two submitted upstream today; the reducer’s own defect (an unterminated tag buffer that made <title> read as titlename=… and swallowed every page body) was found in seconds by building the reducer natively on the host and feeding it the same HTML, after a minute-per-try search on the guest had found nothing. The pattern from the certificate work again: test the piece that can be tested natively, natively.
Verdict
A 30-year-old operating system can negotiate today’s TLS with today’s servers, in a time that a person will wait for. The library is the limiting factor, not the CPU: two protocol defects, not arithmetic, stood between the first hello and a decrypted page. What remains for a usable client is certificate validation, an entropy source, HelloRetryRequest support — and moving the browser that would use it to a compiler that can link the result.
Quellen
- Crypto Ancienne — https://github.com/classilla/cryanc
- Retro68 — https://github.com/autc04/Retro68
- RFC 8446 §4.1.4 (HelloRetryRequest)
- AppleBridge — https://github.com/LoetLuemmel/AppleBridge
