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)
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
- russh
auth_publickey(user, public_key)fires. - alkssh computes
public_key.fingerprint()via thessh-keyre- export (HashAlg::Sha256,SHA256:base64OpenSSH 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). 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 (vanillasshandgitalways send one; ignoring it entirely keeps OpenSSH-tool compatibility:ssh anything@host).- 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-onlyConnection::set_identity(identity)— after whichAccessControl::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— thessh-keyre-export accepts raw ed25519 seed bytes intoKeypairData::Ed25519, but the exact construction (and whatPrivateKeyWithHashAlgneeds for a non-RSA key) needs POC-2 confirmation.- Signing-only alternative: russh's
Signertrait +authenticate_publickey_with(..., signer)exists precisely for keys that shouldn't be extractable. A vault-backedSignerwould 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_identityonce-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::Handlerauth hooks,client::Handleauthenticate methods,Signer,keysmodule (seerussh.md)