# 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 (ADR-010 is alkhttp ADR-014's alkcall analog), alkvault 0.1.0 source, alkhttp architecture docs. ## The server side: keys in, identity out ### What alkcall provides (exact surface) ```rust // /workspace/@alkdev/alkcall/src/core/auth.rs pub struct Identity { pub id: String, // for SSH keys: the SHA-256 fingerprint pub scopes: Vec, pub resources: HashMap>, } pub struct AuthToken { pub raw: Vec } pub struct AuthContext { pub identity: Option, pub alpn: Vec, pub remote_addr: Option, pub tls_client_fingerprint: Option, } pub trait IdentityProvider: Send + Sync + 'static { fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option; fn resolve_from_token(&self, token: &AuthToken) -> Option; } ``` `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::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 | (n/a) | `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 `Secret`s 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, since 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`, so 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`, `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`)