- 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
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
- 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 (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-keyitself supports with a passphrase) is the natural candidate; alkvaultencrypt/decrypt(EncryptedData) is the outer wrapper. - At-use shape: decrypted bytes →
russh::keys::PrivateKey(decode_secret_keyfor OpenSSH encoding) at dial/construction time, held as a zeroizedSecret-like value for the dial's lifetime. russh'sSignertrait 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-shapedKeySourceseam ({ 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 alkvaultEncryptedDataconfirmed as the at-rest winner (4-way comparison; note alkvault'sencrypt/decryptspeakStringonly, so binary formats need a base64 layer); one-timedecode_secret_keyat dial confirmed; ssh-key zeroizes ed25519 key bytes on drop but the vault'sdecryptreturns a plainString— the adapter owns plaintext zeroization at the seam (Zeroizingwrap) [OQ seed]; theSignerseam 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_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— for SSH keys, theencrypt/decrypt/EncryptedDataat-rest surface is the relevant one; the derivation surface is not on this path),src/protocol.rs(EncryptedData) - russh 0.64.1 docs:
server::Handlerauth hooks,client::Handleauthenticate methods,Signer,keysmodule (seerussh.md)