Files
alkssh/docs/research/poc-1-auth-bridge.md
T
glm-5.3-flash 22b82af517 docs(architecture): correct the fingerprint base64 alphabet wording
The format contract (ADR-004 decision 1) is SHA256: + unpadded
standard base64 — not url-safe. The ssh-key re-export and ssh-keygen
both emit the +/ alphabet (visible in the recorded fingerprints); the
'url-safe' wording in ADR-004/auth.md/plans/implementation.md and the
POC-1 §1 finding was a misnomer. Wording corrected; the parity
contract itself unchanged on both sides.
2026-10-28 06:27:49 +00:00

10 KiB

POC-1 findings — auth bridge + fail-closed declines end to end

Status: Complete (phase 0). POC source: /workspace/alkssh-poc1 (standalone crate, disposable — never merges). All hypotheses in pocs.md §POC-1 were confirmed; the doc below records the evidence, one correction to the phase 0 research docs, two API-shape facts, and the OQ seeds this POC produced.

Setup exercised

  • russh 0.64.1 from crates.io (the current published release; crates.io newest_version re-verified during the POC), default features off, crypto backend ring. Clean build under edition 2024, rustc 1.99.0. The ring backend built with no external provisioning — relevant data point for the pin ADR's crypto-backend question.
  • Dependency tree confirms the pin considerations in russh.md: ssh-key 0.7.0-rc.11 exact-pinned and re-exported via russh::keys::ssh_key; rand_core 0.10 in-tree. Key generation in the POC used rand 0.10 (PrivateKey::random(&mut rng(), Algorithm::Ed25519)) and PrivateKey::to_openssh(LineEnding::LF) produced key files vanilla OpenSSH loaded without complaint.
  • Client side: russh client (client::Handler, client::connect) as the primary programmatic client, plus a stub IdentityProvider registered via fingerprints taken from ssh-keygen -lf output (independent OpenSSH implementation) — making the parity check a real cross-implementation check, not a same-code self-check.
  • Vanilla client: OpenSSH 9.6p1 (ssh, ssh -L), isolated UserKnownHostsFile, BatchMode, IdentitiesOnly.

Validated: all checks pass (31/31)

1. Fingerprint-format parity — confirmed, format is exactly the registered string

public_key.fingerprint(HashAlg::Sha256).to_string() (via the ssh-key re-export) yields SHA256:<unpadded base64, standard alphabet> — byte-identical to ssh-keygen -lf on the same public key. (An earlier phrasing of this section called the encoding "url-safe"; the observed strings and OpenSSH's own format use the standard alphabet — unpadded standard base64 is the invariant.) The resolve_from_fingerprint string compare works with values that were registered from OpenSSH tooling output. Parity was additionally observed against the vanilla client's own host-key check (known_hosts entry fingerprints, also SHA256: format).

The auth-bridge checkpoint is closed: the format is stable and self-describing; the assembly layer can register SHA256: strings taken from any standard issuer (ssh-keygen, alkcall-side computation via the same re-export) and the server-side compare holds.

2. Auth bridge end to end — confirmed as designed

  • auth_publickey fires after russh's own signature verification (documented behavior observed: the vanilla key was accepted without any signature handling in alkssh-shaped code). The handler computes the fingerprint, resolves through the stub IdentityProvider, and returns Auth::Accept / Auth::Reject accordingly. Unknown keys decline cleanly.
  • auth_succeeded fired on every successful auth (both clients).
  • auth_publickey_offered accept-and-let-decide works with OpenSSH's probe-then-sign flow: the hook fires (key seen, ownership unverified) before the signed attempt, and does not count toward max_auth_attempts — consistent with russh's own doc warning about keeping the count honest.
  • Username: ignored entirely for identity (keys resolve independently of the username string sent by the client) — supports the "ignore username entirely" OQ direction; ssh anything@host compatibility is unaffected.

3. Decline matrix — every method declined and proven

  • auth_none / auth_password / auth_keyboard_interactive all observed declining (russh client forced each; vanilla client with PubkeyAuthentication=no got Permission denied (publickey) with no fallback attempted).
  • Config::methods advertising only publickey is honored by OpenSSH: it never attempts non-advertised methods.

Correction to the phase 0 research docs (recorded in auth-bridge.md and russh.md): in russh 0.64.1 the default implementations of auth_none, auth_password, auth_keyboard_interactive, auth_publickey, and auth_openssh_certificate are already reject (Auth::reject()). Only auth_publickey_offered defaults to accept. The research assumed older-russh accept-defaults. The fail-closed discipline at 0.64.1 is therefore largely inherited; the family contract still requires implementing every hook explicitly (a future russh bump could change defaults — the pin is a one-way door) and POC-1 proves the explicit implementations end to end.

4. Bounded-resource auth behavior — max_auth_attempts semantics

The initial none probe counts toward max_auth_attempts: with a limit of 3, a BatchMode vanilla client got the none probe plus two signed declined attempts and was disconnected with Received disconnect ...:14: Too many authentication attempts (exit 255). Two practical consequences for phase 1 design:

  • with IdentitiesOnly and multiple keys, clients effectively get max_auth_attempts - 1 real key attempts;
  • the alkforge assembly layer should size the limit with the probe in mind (e.g. limit 10 default is comfortable; a small limit eats one attempt on every connection).

5. Fail-closed channel declines observable by vanilla ssh — confirmed

  • ChannelOpenHandle drop = auto-reject sends AdministrativelyProhibited (verified in russh source ChannelOpenHandleInner::drop, and observed on the wire). No explicit reject call needed anywhere: unfeatured channel types are handled by simply not accepting the handle.
  • Vanilla ssh host after successful auth: channel 0: open failed: administratively prohibited: Administratively prohibited, exit 255 — clean diagnosis, no hang, and the connection-level auth remains intact (the decline is channel-scoped, not a disconnect).
  • Vanilla ssh -L 127.0.0.1:P:127.0.0.1:P -N: the local listener establishes; dialing through it opens a direct-tcpip channel that is refused — the local dial gets TCP-level reset ("Connection reset by peer"), no data ever crosses. The server-side log shows the drop with the forward target.
  • Session remains usable after declines (further channel opens correctly declined too — no state poisoning).

6. russh client API shapes on rejects (new facts for the mapping docs)

  • A rejected channel open is surfaced by the awaiting call: channel_open_session() -> Err(russh::Error::ChannelOpenFailure( AdministrativelyProhibited)). The 0.5x-era pattern of awaiting wait() and matching ChannelMsg::OpenFailure still exists in the message enum but the typed open call resolves to Err first. The mapping-table/POC-3 code should expect Err from the open call.
  • ServerHandler has no channel_open_x11-style escape from fail-closed: every unhandled open type simply isn't accepted; the auto-reject covers x11, direct-tcpip, forwarded-tcpip, direct-streamlocal uniformly.
  • PublicKeyOrCertificate::PublicKey is a struct variant ({ key, hash_alg }) — small API detail when reading host keys in check_server_key.
  • Client-side plumb caveat (recorded for POC-2/3): Channel::wait() on a just-opened client channel before into_stream() breaks the channel (subsequent write_all fails with BrokenPipe "channel closed"). channel_open_session() already awaits confirmation internally; do not consume the channel queue before converting to a stream. With that caveat respected, the end-to-end echo works.

7. Channel ↔ AsyncRead/AsyncWrite plumbing — confirmed

into_stream() works on both sides: server accepts a session channel, channel.into_stream(), reads/writes in a plain AsyncRead/AsyncWrite loop; client channel_open_session() → into_stream() → write_all/read_exact round-trips multiple frames (hello-poc1-echo, second-frame). This is the exact plumbing shape POC-2 (dial lifecycle) and POC-3 (mapping bake-off) will reuse.

Hypothesis verdicts (pocs.md §POC-1)

  • "auth_publickey → fingerprint string parity → resolve → accept; unknown key → clean decline" — confirmed.
  • "auth_publickey_offered accept-and-let-decide with OpenSSH probe-then-sign" — confirmed (and confirmed it doesn't inflate the attempt counter).
  • "Default auth impls are accept — every default must be overridden" — corrected: at 0.64.1 only the offered hook defaults to accept. Explicit implementations still required by the family contract and proven here.
  • "ChannelOpenHandle drop = auto-reject; declines observable by vanilla ssh; clean diagnostics" — confirmed.
  • "Channel ↔ AsyncRead/AsyncWrite (into_stream) works" — confirmed, with the wait-before-into_stream caveat.

OQ seeds carried forward

  1. Username handling: POC ignored it entirely and everything worked (ssh poc@host, ssh anything@host shape). Phase 1 to decide whether usernames are ever logged/matched (vanilla clients always send one).
  2. max_auth_attempts sizing must account for the counting none probe (see §4) — a config default note for the ADR.
  3. Vanilla clients probe with none first; a future russh bump that changes reject-defaults would be a silent-auth posture change — reinforces recording the russh pin as a deliberate one-way door.
  4. auth_openssh_certificate override-decline exists but was not exercised with a real certificate (no CA in the POC) — the "decline initially" row stands unfalsified.

Reference run

Full output of a clean run at pocs.md commit time: 31 checks, 6 groups (fingerprint parity 2, russh client auth 9, channel declines 3, vanilla OpenSSH 11, max_auth_attempts 1, echo plumbing 5) — all PASS. Vanilla diagnostic strings quoted above are verbatim from that run.

References

  • pocs.md §POC-1 (plan), auth-bridge.md (design), russh.md (API inventory — updated with the 0.64.1 default-impl correction)
  • POC source: /workspace/alkssh-poc1 (single src/main.rs + stub alkcall-shaped IdentityProvider in authbridge module)
  • russh 0.64.1 (crates.io, ring backend), ssh-key 0.7.0-rc.11 (re-export), OpenSSH 9.6p1