- 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
230 lines
12 KiB
Markdown
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`) |