docs(research): vault's SSH role is at-rest secret storage, not key derivation
A conflation in the research set: alkvault's derive-and-custody surface (derive_ed25519, SLIP-0010 paths) was being sketched as the SSH key path. Corrected across the docs — held SSH keys (client outgoing keys and the server's own host key) are bearer-token-class secrets: opaque bytes the family holds, vault-encrypted at rest, decrypted at use (the alkhttp ADR-014 posture). No derivation story, no vault-custody design, and the Signer-injection idea is withdrawn and recorded with its dismissal rationale. - auth-bridge.md: client-side gap re-framed as at-rest storage — the at-rest shape (OpenSSH encoding inside alkvault EncryptedData), the at-use lifecycle (decrypt-at-dial, one-time decode_secret_key, zeroized for the dial), the KeySource-like source shape - pocs.md: POC-2 rewritten to at-rest key handling (formats comparison, decrypt-at-use lifecycle, locked-vault fail-closed, Signer dismissal) - vision.md: principle 5 scoped (secret store, not key generator/custodian) - russh.md: Signer seam noted as not presumed to be the vault shape - AGENTS.md/CHANGELOG: convention 6 + context updated to match Verification: cargo doc --no-deps, cargo fmt --check, cargo test (docs-only change)
This commit is contained in:
1 parent
ecdf836eeb
commit
f4424b7e58
6 files changed
+105
-73
No files matched your search
@@ -104,7 +104,9 @@ 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).
|
||||
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)
|
||||
|
||||
@@ -114,41 +116,57 @@ 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.
|
||||
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 → russh key
|
||||
### The gap: alkvault-backed key storage
|
||||
|
||||
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:
|
||||
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.
|
||||
|
||||
- `DerivedKey.private_key` (raw ed25519 bytes) → `russh::keys::PrivateKey`
|
||||
— the `ssh-key` re-export accepts raw ed25519 seed bytes into
|
||||
`KeypairData::Ed25519`, but the exact construction (and what
|
||||
`PrivateKeyWithHashAlg` needs for a non-RSA key) needs POC-2
|
||||
confirmation.
|
||||
- **Signing-only alternative**: russh's `Signer` trait +
|
||||
`authenticate_publickey_with(..., signer)` exists precisely for keys
|
||||
that shouldn't be extractable. A vault-backed `Signer` would 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.
|
||||
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.
|
||||
- **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, ideally from the same vault
|
||||
path family (`PATHS::SSH_HOST` exists deliberately). The client's
|
||||
`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
|
||||
@@ -170,7 +188,9 @@ for the ADR.
|
||||
(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)
|
||||
- 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`)
|
||||
+33
-30
@@ -47,42 +47,45 @@ keys; russh client available as a secondary client for
|
||||
uncertainty is fingerprint-format parity details, which this either
|
||||
confirms or corrects.
|
||||
|
||||
## POC-2 — client-side vault → russh key bridge (separate by nature)
|
||||
## POC-2 — client-side at-rest key handling (vault-backed storage)
|
||||
|
||||
**Question:** how does an alkvault-derived key become a russh client
|
||||
key without the vault leaking the bytes — `PrivateKey` extraction vs
|
||||
a vault-backed `Signer`?
|
||||
**Question:** how are held SSH keys stored and used — the vault as
|
||||
pure at-rest encryption (the key is just another bearer-token-class
|
||||
secret: opaque bytes, vault-encrypted at rest, decrypted at use), what
|
||||
byte format sits inside, and how does the decrypt→dial lifecycle
|
||||
behave?
|
||||
|
||||
**Construction:** standalone. alkvault `VaultServiceHandle` +
|
||||
`derive_ed25519` (host/device paths) → two candidate shapes:
|
||||
(a) `ssh-key` `KeypairData::Ed25519` from raw seed bytes →
|
||||
`russh::keys::PrivateKey` → `authenticate_publickey`; (b) a vault-
|
||||
backed `russh::Signer` fed to `authenticate_publickey_with` (bytes
|
||||
never leave the vault struct).
|
||||
**Construction:** standalone. alkvault `encrypt`/`decrypt`
|
||||
(`EncryptedData`) wrapping an OpenSSH-encoded private key (generated
|
||||
in-POC) → `ssh-key` `decode_secret_key` → `russh::keys::PrivateKey` →
|
||||
`authenticate_publickey` against POC-1's server loopback (which resolves
|
||||
the key's fingerprint through the same stub `IdentityProvider`).
|
||||
|
||||
**Validates:**
|
||||
- Whether ed25519 seed bytes → `ssh-key` key data round-trips
|
||||
(`KeypairData` expects the *expanded* keypair — seed-to-`[u8; 64]`
|
||||
expansion is a real step; whether alkvault's `DerivedKey` carries
|
||||
the expanded form or just the seed is an open sub-question).
|
||||
- Whether `Signer` + `authenticate_publickey_with` + `PublicKey`
|
||||
(public half held outside the vault) is ergonomic, and what
|
||||
`PrivateKeyWithHashAlg` wants for non-RSA keys.
|
||||
- Server side of the same loopback re-uses POC-1's fingerprint path —
|
||||
client key derived in vault, server resolves its fingerprint.
|
||||
- Lifecycle correctness: derived key zeroization across the boundary;
|
||||
vault lock/unlock races.
|
||||
- The at-rest shape: OpenSSH private-key encoding as the inner format
|
||||
vs raw seed bytes or PKCS#8 (the ssh-key crate's own encrypted-key
|
||||
format with a passphrase is an alternative wrapper worth comparing
|
||||
against alkvault's outer encryption).
|
||||
- The at-use lifecycle: decrypt-at-dial → one-time `decode_secret_key`
|
||||
→ zeroized handling for the dial's lifetime; what happens locked /
|
||||
absent (fail-closed no-dial) / mid-session unlock rotation.
|
||||
- Whether a `Signer`-shaped seam is still worth anything given the
|
||||
bytes are handled client-side at use anyway (expected answer: no —
|
||||
recorded so the ADR can dismiss it with evidence).
|
||||
- Server and client host-key material riding the same model.
|
||||
|
||||
**Scope note (per the design split):** the vault is for secrets *we*
|
||||
hold — client-side outgoing keys and the server's own host key — and
|
||||
is explicitly not involved in resolving *clients'* fingerprints
|
||||
(that path is the assembly-layer `IdentityProvider`). Kept as its own
|
||||
POC because its failure modes (key-material lifecycle) are unrelated
|
||||
to the auth-flow failure modes of POC-1.
|
||||
**Scope note:** the vault is for secrets *we* hold — client-side
|
||||
outgoing keys and the server's own host key — and is explicitly not
|
||||
involved in resolving *clients'* fingerprints (that path is the
|
||||
assembly-layer `IdentityProvider`). The vault's derivation surface
|
||||
(`derive_ed25519`, SLIP-0010 paths) is not on this path either — that
|
||||
is a different alkvault feature, not SSH key custody. Kept as its own
|
||||
POC because its failure modes (at-rest/lifecycle, zeroization, lock
|
||||
behavior) are unrelated to the auth-flow failure modes of POC-1.
|
||||
|
||||
**Hypothesized outcome:** shape (b) (vault-backed `Signer`) is the
|
||||
preferable design; if `Signer` friction proves high, shape (a) with a
|
||||
zeroizing wrapper is documented with its exposure caveat.
|
||||
**Hypothesized outcome:** OpenSSH-encoded key inside alkvault
|
||||
`EncryptedData`, one-time `decode_secret_key` at dial, zeroizing
|
||||
wrapper — confirming the alkhttp ADR-014 analogy end to end.
|
||||
|
||||
## POC-3 — the mapping bake-off: channel bridge through a real sibling seam
|
||||
|
||||
|
||||
@@ -135,8 +135,11 @@ PrivateKeyWithHashAlg)`, `authenticate_password`,
|
||||
`authenticate_none`, `authenticate_keyboard_interactive_start/
|
||||
respond`, `authenticate_openssh_cert`, the `*_with` signer-injected
|
||||
variants (`authenticate_publickey_with(user, PublicKey, hash_alg,
|
||||
&mut Signer)` — the seam for vault-backed signers that never expose
|
||||
the private key), `authenticate_gssapi_with_mic`,
|
||||
&mut Signer)` — a signer-injection seam; under alkssh's at-rest
|
||||
storage model (held keys are bearer-token-class secrets decrypted at
|
||||
use — see `auth-bridge.md`) it is not presumed to be the vault
|
||||
integration shape, but it exists and POC-2 evaluates it against the
|
||||
simple decrypt-and-decode flow), `authenticate_gssapi_with_mic`,
|
||||
`best_supported_rsa_hash` (waits ≤1s for EXT_INFO `server-sig-algs`).
|
||||
|
||||
Channel opening: only the four typed opens exist —
|
||||
|
||||
@@ -50,11 +50,13 @@ not protocol invention.
|
||||
exactly one point per mapping. No channel-type knowledge flows
|
||||
upstream into the protocol crates.
|
||||
5. **Identity is the family's, not SSH's.** SSH user names, `~/.ssh/`
|
||||
conventions, and password auth are not the alk family's identity
|
||||
conventions, and password auth are not the alk family's identity
|
||||
model. A verified key's fingerprint → `Identity` via alkcall's
|
||||
`IdentityProvider` is the only resolution path; secrets an alkssh
|
||||
*client* holds come from alkvault-backed sources, never env vars
|
||||
(family ADR-002 decision 5).
|
||||
*client* holds come from alkvault-backed sources — vault-encrypted
|
||||
at rest, treated as bearer-token-class secret material — never env
|
||||
vars (family ADR-002 decision 5). The vault is a secret store on
|
||||
this path, not a key generator or key custodian.
|
||||
|
||||
## Non-goals
|
||||
|
||||
@@ -90,11 +92,11 @@ phase 1's `open-questions.md`:
|
||||
§topology).
|
||||
2. **russh pin** — version, crypto-backend feature, and the
|
||||
RC-pinned transitive dependency situation (see `russh.md`).
|
||||
3. **Feature names + graph** — the `session`/`tunnel`/`dynamic-`-`forward`/`git`/`sftp` naming and their independence
|
||||
3. **Feature names + graph** — the `session`/`tunnel`/`dynamic-forward`/`git`/`sftp` naming and their independence
|
||||
(no feature implies another; flat until architecture says otherwise).
|
||||
4. **Auth dispositions** — which russh auth methods the server
|
||||
implements vs declines; the exact `auth_publickey` → fingerprint →
|
||||
identity contract; the client-side vault→russh key bridge
|
||||
identity contract; client-side vault-backed at-rest key handling
|
||||
(see `auth-bridge.md`).
|
||||
5. **The mapping table details** — per channel type and per request,
|
||||
the family seam, the parameter mapping, and the decline contract
|
||||
|
||||
Reference in new issue
Block a user