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

230 lines
12 KiB
Markdown

# 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)
```rust
// /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 `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 — 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`)