- new: docs/research/poc-1-auth-bridge.md (standalone POC at /workspace/alkssh-poc1; russh 0.64.1 + OpenSSH 9.6p1; 31/31 checks) - confirmed hypotheses: SHA256 fingerprint parity across russh/ ssh-keygen/vanilla known_hosts; auth_publickey -> IdentityProvider resolve -> accept/decline; offered-hook accept-and-let-decide with OpenSSH probe-then-sign; ChannelOpenHandle drop = auto-reject (AdministrativelyProhibited) with clean vanilla diagnostics; max_auth_attempts counts the initial none probe; into_stream echo plumbing works both sides - correction recorded to auth-bridge.md + russh.md: 0.64.1 default auth impls reject (only publickey_offered defaults to accept) - new API fact in russh.md: rejected channel opens surface as Err from the typed open call - OQ seeds: username-ignore direction, max_auth_attempts sizing, pin as one-way door (reject-defaults could change on bump)
9.9 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_versionre-verified during the POC), default features off, crypto backendring. Clean build under edition 2024, rustc 1.99.0. Theringbackend 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.11exact-pinned and re-exported viarussh::keys::ssh_key;rand_core 0.10in-tree. Key generation in the POC usedrand 0.10(PrivateKey::random(&mut rng(), Algorithm::Ed25519)) andPrivateKey::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 stubIdentityProviderregistered via fingerprints taken fromssh-keygen -lfoutput (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), isolatedUserKnownHostsFile,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:<url-safe base64, unpadded> —
byte-identical to ssh-keygen -lf on the same public key. 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_publickeyfires 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 stubIdentityProvider, and returnsAuth::Accept/Auth::Rejectaccordingly. Unknown keys decline cleanly.auth_succeededfired on every successful auth (both clients).auth_publickey_offeredaccept-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 towardmax_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@hostcompatibility is unaffected.
3. Decline matrix — every method declined and proven
auth_none/auth_password/auth_keyboard_interactiveall observed declining (russh client forced each; vanilla client withPubkeyAuthentication=nogotPermission denied (publickey)with no fallback attempted).Config::methodsadvertising onlypublickeyis 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
IdentitiesOnlyand multiple keys, clients effectively getmax_auth_attempts - 1real 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
ChannelOpenHandledrop = auto-reject sendsAdministrativelyProhibited(verified in russh sourceChannelOpenHandleInner::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 hostafter 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 adirect-tcpipchannel 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 awaitingwait()and matchingChannelMsg::OpenFailurestill exists in the message enum but the typed open call resolves toErrfirst. The mapping-table/POC-3 code should expectErrfrom the open call. ServerHandlerhas nochannel_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::PublicKeyis a struct variant ({ key, hash_alg }) — small API detail when reading host keys incheck_server_key.- Client-side plumb caveat (recorded for POC-2/3):
Channel::wait()on a just-opened client channel beforeinto_stream()breaks the channel (subsequentwrite_allfails withBrokenPipe "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_offeredaccept-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.
- "
ChannelOpenHandledrop = 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
- Username handling: POC ignored it entirely and everything worked
(
ssh poc@host,ssh anything@hostshape). Phase 1 to decide whether usernames are ever logged/matched (vanilla clients always send one). max_auth_attemptssizing must account for the countingnoneprobe (see §4) — a config default note for the ADR.- Vanilla clients probe with
nonefirst; 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. auth_openssh_certificateoverride-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(singlesrc/main.rs+ stub alkcall-shapedIdentityProviderinauthbridgemodule) - russh 0.64.1 (crates.io, ring backend), ssh-key 0.7.0-rc.11 (re-export), OpenSSH 9.6p1