Files
alkssh/docs/research/auth-bridge.md
T
glm-5.3-flash ecdf836eeb docs(research): phase 0 research set — vision, russh, auth bridge, mapping table, POC plan
The phase 0 document set (documented only; no POCs run yet):

- docs/research/vision.md — guiding principles (russh owns the wire,
  door-not-backend, fail-closed everything, one seam per mapping,
  family identity not SSH identity); non-goals (no sshd, no sftp until
  alkfs, no password auth); the five scope questions phase 0/1 must
  answer
- docs/research/russh.md — 0.64.1-era inventory: server/client
  Handler method tables, Config knobs, keys module (ssh-key re-export,
  fingerprint API), the RC-pinned dependency tree (ssh-key, rsa,
  pkcs1), MSRV/edition/license posture; alknet-main referenced as a
  rough example only, explicitly not decision input
- docs/research/auth-bridge.md — the alkcall mapping: no
  resolve_from_signature exists; the actual surface is
  IdentityProvider::resolve_from_fingerprint fed by russh-verified key
  fingerprints in auth_publickey; auth-method disposition matrix
  (only publickey implemented, all defaults overridden); client side
  mirrors alkhttp ADR-014 (key sources, not static keys; vault
  client-side only); the vault-to-russh-key bridge named as the
  missing design piece
- docs/research/mapping-table.md — the spine: channel-type rows with
  feature candidates and the verified sibling serve/consume entries
  (alktty drive_session, alkgit serve_duplex_resolved, alksocks
  serve_socks5_over, alktunnels consumer wrappers + the no-serve-over-
  stream absence note); request-level rows; parse-then-route dispatch
  inside the session channel; the alk/ssh ALPN topology question
  (ADR-004 reserved / ADR-034 stream_types [0,1] / ADR-042 hub never
  runs it); alktunnels route choice flagged for ADR
- docs/research/pocs.md — POC-1 (auth bridge + fail-closed declines
  against vanilla OpenSSH), POC-2 (vault-to-russh key bridge:
  PrivateKey extraction vs vault-backed Signer), POC-3 (mapping
  bake-off stages A-D: git exec, socks -D, tunnel -L, pty); all
  standalone mode; findings docs are the deliverables

Verification: cargo doc --no-deps, cargo fmt --check, cargo test
(docs-only change; lib untouched)
2026-10-25 10:13:17 +00:00

8.7 KiB

Auth bridge — russh auth ↔ alkcall identity

Status: Draft (phase 0 research). Sources: alkcall 0.10.1 source (src/core/auth.rs, src/core/types.rs, src/protocol/dispatch.rs), alkcall ADRs 002/003/006/010/014-analog (alkhttp ADR-014), alkvault 0.1.0 source, alkhttp architecture docs.

The server side: keys in, identity out

What alkcall provides (exact surface)

// /workspace/@alkdev/alkcall/src/core/auth.rs
pub struct Identity {
    pub id: String,                              // for SSH keys: the SHA-256 fingerprint
    pub scopes: Vec<String>,
    pub resources: HashMap<String, Vec<String>>,
}
pub struct AuthToken { pub raw: Vec<u8> }
pub struct AuthContext {
    pub identity: Option<Identity>,
    pub alpn: Vec<u8>,
    pub remote_addr: Option<SocketAddr>,
    pub tls_client_fingerprint: Option<String>,
}

pub trait IdentityProvider: Send + Sync + 'static {
    fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity>;
    fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity>;
}

IdentityProvider is a trait only — implementations are supplied by the assembly layer (alkcall ships NoopIdentityProvider, which resolves nothing). alkhttp's server resolves tokens through exactly this one backend for every inbound surface (bearer + door-Basic, alkhttp ADR-073); alkssh is the same posture: one backend, an SSH- shaped front.

Correction to a working assumption: there is no resolve_from_signature anywhere in alkcall (nor in the ADR). The credential table in alkcall ADR-003 maps SshAdapter to resolve_from_fingerprint. The division of labor works because russh does the cryptographic verification: by the time Handler::auth_publickey(user, &PublicKey) fires, russh has verified the signature and key ownership (doc: "called after the signature has been verified and key ownership has been confirmed"). alkssh never sees or verifies a raw signature; it consumes the fingerprint of an already-verified key.

The mapping

  1. russh auth_publickey(user, public_key) fires.
  2. alkssh computes public_key.fingerprint() via the ssh-key re- export (HashAlg::Sha256, SHA256:base64 OpenSSH format) — fingerprint-format parity with the assembly layer's registered identities is a contract invariant and a POC-1 checkpoint (the format is stable and self-describing, but the registration path is assembly-owned, so the exact string compare must be verified, not assumed).
  3. IdentityProvider::resolve_from_fingerprint(fp) — Some(identity) → Result<Auth, _>::Authenticate { .. }; None → decline (Reject). The user name string is ignored for identity (it is not the family identity model); whether the username is checked at all vs. ignored is an OQ candidate (vanilla ssh and git always send one; ignoring it entirely keeps OpenSSH-tool compatibility: ssh anything@host).
  4. On success, the resolved identity rides the session: auth_succeeded(session) attaches it to alkssh's per-session state; when the session is bridged over alkcall, the identity is pushed with the once-only Connection::set_identity(identity) — after which AccessControl::check(peer_identity) gates every mapped channel open exactly as in alkhttp (visible-surface = authorized-surface). Unresolvable keys decline fail-closed; the resolved-identity-None path must not create a "weakly authorized" session.

auth_publickey_offered exists as the cheap pre-auth hook (key ownership unverified there); the constant-rejection-time contract (Config::auth_rejection_time) plus Config::max_auth_attempts covers the timing/DoS posture. Note russh's own doc warning: seeing an unknown key at the offered stage should not count toward the attempt limit — the offered hook's disposition is "accept the offer, let auth_publickey decide", which keeps counting honest.

Auth-method disposition (fail-closed matrix)

russh hook Disposition Rationale
auth_publickey Implement — the only method The family identity model is keys; fingerprint → Identity
auth_publickey_offered Implement (accept-and-let-decide) Keeps max_auth_attempts honest
auth_none Decline (default impl accepts — override) Fail-closed is ours to enforce
auth_password Decline No password concept in family identity
auth_keyboard_interactive Decline Same
auth_openssh_certificate Decline initially (OQ candidate) OpenSSH CA trust model ≠ family key registry; revisit on a use case
authentication_banner Assembly-provided (e.g. alkforge notice) or none —

Config::methods: MethodSet must advertise exactly what is implemented (honest advertisement = only publickey in every current configuration).

The client side: keys out (we hold the secret)

Vanilla SSH's client model differs from the family's protocols: the client signs with a private key; the server verifies. alkssh's client side therefore holds secret material, and the family rule applies: that material comes from alkvault-backed sources, never env vars, never plaintext at rest (family ADR-002 decision 5).

The alkhttp pattern being mirrored (alkhttp ADR-014)

alkhttp's client adapters (from_openapi/from_mcp) never hold static tokens in config: the config declares the scheme, the assembly layer injects the secret as an alkcall Capabilities entry at registration, and the adapter reads it fail-closed at use time (Capabilities::get → absent → loud internal error, never an unauthenticated send). The alkssh translation: the client adapter takes key sources, not static keys — the assembly layer supplies an alkvault-backed source; the adapter converts it into a russh-usable key/signer at construction or dial time; absent/locked vault → fail-closed decline, no anonymous dial.

The gap: alkvault → russh key

alkvault 0.1.0 is a derive-and-encrypt vault, not a KeySource KV store: VaultServiceHandle::derive_ed25519(path) -> Result<DerivedKey> (SLIP- 0010 ed25519 over the BIP39 seed; DerivedKey { key_type, private_key (zeroized), public_key }), with derivation paths including PATHS::SSH_HOST and a device_path(index) family. The bridge that does not exist yet, and is alkssh phase 0/1 design work:

  • DerivedKey.private_key (raw ed25519 bytes) → russh::keys::PrivateKey — the ssh-key re-export accepts raw ed25519 seed bytes into KeypairData::Ed25519, but the exact construction (and what PrivateKeyWithHashAlg needs for a non-RSA key) needs POC-2 confirmation.
  • Signing-only alternative: russh's Signer trait + authenticate_publickey_with(..., signer) exists precisely for keys that shouldn't be extractable. A vault-backed Signer would keep the vault as the only place the bytes exist — candidate preferred shape, POC-2's design question.
  • Scope note: the vault is client-side only. The server side never touches the vault — it receives clients' public keys and resolves fingerprints through the assembly layer's IdentityProvider. Vault material is for secrets we hold, not for other parties' fingerprints.

Host keys and check_server_key

The server's host identity (Config::keys: Vec<PrivateKey>, certificates) is assembly-configured, ideally from the same vault path family (PATHS::SSH_HOST exists deliberately). The client's Handler::check_server_key defaults to reject all — russh forces a deliberate host-key policy. Family posture (no TOFU): pin expected host fingerprints at the assembly layer; check_known_hosts exists in russh, but the family identity registry is likely the better pin store (OQ candidate). hostkeys_prove / openssh_ext_host_keys_announced (RSA-AKCE-style proving, new in 0.64.0) is an optional hardening note for the ADR.

References

  • alkcall: src/core/auth.rs (Identity/AuthToken/AuthContext/ IdentityProvider verbatim above), src/core/types.rs (set_identity once-only), src/protocol/dispatch.rs (resolve_identity — token wins per-request, else connection identity), src/registry/spec.rs (AccessControl::check)
  • alkcall ADRs: 002 (ProtocolHandler, hybrid auth completion — "handlers must not assume AuthContext is fully resolved"), 003 (auth-as-shared-core; the SshAdapter credential-table row), 006 (AuthContext shape/set_identity), 010 (secret-material flow)
  • alkhttp ADR-014 (secret material flow + capability injection — the client-side pattern), ADR-073 (one backend behind every door surface)
  • alkvault 0.1.0: src/service.rs (VaultServiceHandle), src/derivation.rs (PATHS), src/protocol.rs (DerivedKey)
  • russh 0.64.1 docs: server::Handler auth hooks, client::Handle authenticate methods, Signer, keys module (see russh.md)