Files
alkssh/docs/research/auth-bridge.md
T
glm-5.3-flash 2aead32fff docs(research): POC-1 findings — auth bridge + fail-closed declines verified end to end
- 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)
2026-10-25 11:23:55 +00:00

10 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 (implement explicitly) Fail-closed is ours to enforce
auth_password Decline (implement explicitly) No password concept in family identity
auth_keyboard_interactive Decline (implement explicitly) 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).

0.64.1 default-implementation note (verified in source and by POC-1; corrects an earlier working assumption): the default impls of auth_none, auth_password, auth_keyboard_interactive, auth_publickey, and auth_openssh_certificate are reject (Auth::reject()) in 0.64.1 — only auth_publickey_offered defaults to accept. The fail-closed posture is therefore largely inherited at this pin, but every hook is still implemented explicitly as a family contract (a future pin bump could alter defaults; the pin is a one-way door), and POC-1 proves all of the declines end to end.

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 guiding framing: a held SSH key is just another bearer-token-class secret — opaque bytes the family holds, vault-encrypted at rest, decrypted at use.

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 encrypted-at-rest blob); the adapter decodes it into a russh-usable key at dial time; absent/locked vault → fail-closed decline, no anonymous dial.

The gap: alkvault-backed key storage

alkvault's role for SSH keys is at-rest encryption, nothing more — it is to an SSH client key what it is to every outgoing secret: a secret store. Held key material is treated exactly like a random bearer token — opaque bytes the family holds, vault-encrypted at rest, decrypted at use (the alkhttp ADR-014 model: vault is assembly-layer only, secrets are zeroized Secrets at the adapter, never env vars). alkvault's derivation surface (derive_ed25519, SLIP-0010 paths) is a different feature of that crate and is not the SSH-keys path — there is no derivation story here, and no vault-custody design where key bytes never leave the vault.

The bridge that does not exist yet, and is alkssh phase 0/1 design work:

  • At-rest shape: what byte format sits in the vault store — OpenSSH private-key encoding (the format ssh-key itself supports with a passphrase) is the natural candidate; alkvault encrypt/ decrypt (EncryptedData) is the outer wrapper.
  • At-use shape: decrypted bytes → russh::keys::PrivateKey (decode_secret_key for OpenSSH encoding) at dial/construction time, held as a zeroized Secret-like value for the dial's lifetime. russh's Signer trait was considered as an extraction-avoidance shape; withdrawn — the at-rest model implies the bytes are handled client-side at use time anyway (as every bearer-token-like secret is), so a one-time decode at the adapter boundary is the honest shape. Lifecycle questions (zeroization bounds, locked-vault behavior → fail-closed no-dial) are POC-2's checkpoints.
  • Source shape: the adapter takes key sources, not static keys — an alknet-main-shaped KeySource seam ({ File, Memory }-equivalent) backed by vault-encrypted material at the assembly layer, mirroring alkhttp's capability injection.
  • Scope note: the vault is client-side only. The server side never touches the vault for peer identity — it receives clients' public keys and resolves fingerprints through the assembly layer's IdentityProvider. The server's own host key, though, is also a held secret and uses the same at-rest model. 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 — a held secret exactly like a client key, same at-rest model as above. 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 — for SSH keys, the encrypt/decrypt/EncryptedData at-rest surface is the relevant one; the derivation surface is not on this path), src/protocol.rs (EncryptedData)
  • russh 0.64.1 docs: server::Handler auth hooks, client::Handle authenticate methods, Signer, keys module (see russh.md)