Files
alkssh/docs/research/auth-bridge.md
T
glm-5.3-flash 04f48ab9bc docs(research): client host-key policy resolved to a two-case policy seam
- OQ-05 resolved: known-beforehand hosts (assembly-minted/family runners)
  -> pinned, hard-decline on mismatch; unknown-beforehand hosts (rental
  fleets) -> assembly-provided HostKeyPolicy the crate calls — prompt-
  record, provider-metadata match, or learn-and-record. Crate default
  stays fail-closed reject-unknown; crate never decides trust; storage
  of learned pins is assembly detail
- auth-bridge.md: refinement postscript — no-TOFU is a no-trust-decisions-
  -in-crate rule, not pin-everything; alkminer's accept-all was the fleet-
  churn collapse, and the real anchor for ephemeral fleets is hub-held
  key material, not host pins
- reverse-runner.md §5 rewritten with the same nuance
2026-10-26 12:04:41 +00:00

12 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.
  • POC-2 verdicts (see poc-2-vault-keys.md): OpenSSH-PEM inner in alkvault EncryptedData confirmed as the at-rest winner (4-way comparison; note alkvault's encrypt/decrypt speak String only, so binary formats need a base64 layer); one-time decode_secret_key at dial confirmed; ssh-key zeroizes ed25519 key bytes on drop but the vault's decrypt returns a plain String — the adapter owns plaintext zeroization at the seam (Zeroizing wrap) [OQ seed]; the Signer seam is dismissed with evidence (no provided impl in 0.64.1; works when hand-rolled; in-process vault gains nothing). Locked/absent/tampered/rotation all fail closed through distinct, mappable error shapes.
  • 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.

Post-POC refinement (client-direction host-policy cases; see reverse-runner.md and OQ-05 in the architecture tracker): the no-TOFU rule is a rule about trust anchors, not an absolute. Two client cases: (1) hosts the assembly controls or can provision — the host key is knowable beforehand (family-built runners mint theirs at assembly time) → pinned, hard-decline on mismatch; (2) hosts that are unknown until first contact (rental-fleet vanilla sshd) → the OpenSSH "add to known_hosts?" moment becomes an assembly-provided host-key policy seam the crate calls with (host, port, fingerprint); the crate default is fail-closed (reject unknown), never auto-trust, never accept-all. Trust policy is the assembly's; the crate is policy-blind.

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)