Sync architecture specs with Phase 2 research findings

- Add definitions.md: normative terminology disambiguation (Interface, Service,
  Transport, Token, Identity, Domain, Scope, CredentialProvider, etc.)
- Add credentials.md: CredentialProvider trait and CredentialSet enum for
  outbound auth, mirroring IdentityProvider pattern for inbound auth
- Rewrite interface.md: StreamInterface/MessageInterface split (ADR-035),
  InterfaceRequest/InterfaceResponse, HttpInterface/DnsInterface stubs,
  ListenerConfig with Stream/Http/Dns variants, credential presentation table
- Update auth.md: API keys in DynamicConfig (ADR-037), credential presentation
  per (Transport, Interface) pair, ApiKeyEntry struct in AuthPolicy
- Update configuration.md: API keys, ListenerConfig with Http/Dns variants,
  expanded TOML config examples
- Update call-protocol.md: resolve OQ-IF-01 (InterfaceEvent carries
  EventEnvelope + Identity), add MessageInterface awareness to protocol
  adapter layer
- Update overview.md: three-layer model now includes StreamInterface/
  MessageInterface, CredentialProvider/CredentialSet exports, definitions.md
  reference, ADRs 035-037
- Update open-questions.md: resolve OQ-IF-01, OQ-IF-02, add OQ-P2-01
  through OQ-P2-04, add OQ-CP-01 through OQ-CP-04, add OQ-DEF-01,
  OQ-DEF-03, OQ-DEF-08
- Update README.md: add definitions.md, credentials.md, ADRs 035-037,
  phase2 research docs, current state description

Key architectural decisions:
- ADR-035: StreamInterface/MessageInterface split (two Layer 2 traits)
- ADR-036: CredentialProvider as core type (outbound auth, alknet_core::credentials)
- ADR-037: API keys as DynamicConfig auth (hash-verified bearer tokens)
This commit is contained in:
2026-06-09 08:09:45 +00:00
parent d1af216334
commit cfc44008d3
12 changed files with 1314 additions and 151 deletions

View File

@@ -0,0 +1,65 @@
# ADR-035: StreamInterface and MessageInterface Split
## Status
Accepted
## Context
The `Interface` trait (ADR-026) assumes a persistent byte stream from a `Transport`. It produces a `Session` that yields `InterfaceEvent` frames. This works for SSH and raw framing — both run over duplex streams.
However, HTTP and DNS do not fit this model. They handle individual request/response pairs, not persistent sessions. HTTP runs over a TLS connection after byte-peek protocol detection (extending the existing stealth mode pattern). DNS runs its own server on port 53. Both are stateless per-request, not session-oriented.
The three-layer model (Transport, Interface, Protocol) remains correct. The issue is that Layer 2 has two distinct patterns: stream-based (SSH, raw framing) where the transport provides a continuous byte stream, and message-based (HTTP, DNS) where the interface manages its own transport and handles discrete requests.
## Decision
Split the `Interface` trait into two independent traits:
1. **`StreamInterface`** — consumes a `TransportStream`, produces a long-lived `Session` that yields `InterfaceEvent` frames. Existing `SshInterface` and `RawFramingInterface` become `StreamInterface` implementations.
2. **`MessageInterface`** — handles individual `InterfaceRequest``InterfaceResponse` pairs. Manages its own transport (HTTP server, DNS server). `HttpInterface` and `DnsInterface` are `MessageInterface` implementations.
The traits are independent. They have different signatures (`accept(stream)` vs `handle_request(req)`), different lifecycles (long-lived session vs stateless per-request), and different transport ownership (provided by caller vs self-managed).
`ListenerConfig` gains variants for both:
```rust
pub enum ListenerConfig {
Stream {
transport: TransportKind,
interface: StreamInterfaceKind,
},
Http {
bind_addr: SocketAddr,
tls: bool,
stealth: bool,
},
Dns {
bind_addr: SocketAddr,
tls: bool,
},
}
```
`TransportKind::Dns` is removed. DNS is a `MessageInterface` that manages its own transport (UDP/TCP port 53), not a transport variant.
The call protocol handler (Layer 3) is interface-agnostic: it processes `InterfaceEvent` frames from `StreamInterface` sessions and `InterfaceRequest``InterfaceResponse` from `MessageInterface` handlers. The dispatch logic is the same — only the framing differs.
## Consequences
**Positive**: HTTP and DNS are first-class interfaces with proper type signatures. No forcing stateless protocols into a session model. The existing stealth mode byte-peek pattern naturally extends to `HttpInterface`. The `InterfaceRequest` / `InterfaceResponse` types normalize calls across message-based interfaces.
**Positive**: Removing `TransportKind::Dns` prevents a breaking change later — code should never depend on DNS as a transport variant.
**Positive**: `ListenerConfig` correctly models the server's accept loop: stream listeners spawn one accept loop per (transport, interface) pair, while HTTP and DNS listeners each manage their own server.
**Negative**: Two traits where there was one. But they serve fundamentally different purposes. A common super-trait would add complexity (`accept_stream` + `handle_request` + `transport_kind`) without practical benefit — implementations satisfy one trait or the other, never both.
**Negative**: The `accept()` method on the current `Interface` trait needs to be renamed. This is a rename of an existing method signature, not a semantic change — `SshInterface` and `RawFramingInterface` implementations become `StreamInterface` implementations with the same `accept()` logic.
## References
- ADR-026 (transport/interface separation — updated by this ADR)
- [interface.md](../interface.md) — Interface layer spec
- [research/phase2/interface-model.md](../../research/phase2/interface-model.md) — Full analysis
- [research/phase2/tls-transport.md](../../research/phase2/tls-transport.md) — HTTP interface, ListenerConfig

View File

@@ -0,0 +1,82 @@
# ADR-036: CredentialProvider as Core Type
## Status
Accepted
## Context
Alknet's `IdentityProvider` resolves **inbound** authentication: given a
credential (fingerprint or token), produce an `Identity`. But there is no
corresponding abstraction for **outbound** credentials: how does alknet
authenticate _to_ external services (vast.ai, rustfs, gitea)?
Without `CredentialProvider`, each service wrapper would independently solve
credential retrieval, caching, and lifecycle management. This leads to
duplicated effort and inconsistent security practices across service wrappers.
The pattern mirrors the existing `IdentityProvider` pattern: trait in core,
default impl using simple storage, production impl using the secret service
and database.
## Decision
Define `CredentialProvider` trait and `CredentialSet` enum in
`alknet_core::credentials`.
```rust
pub trait CredentialProvider: Send + Sync + 'static {
fn get_credentials(&self, service: &str) -> Option<CredentialSet>;
fn refresh_credentials(&self, service: &str) -> Option<CredentialSet>;
}
pub enum CredentialSet {
ApiKey { header_name: String, token: String },
Basic { username: String, password: String },
Bearer { token: String },
S3AccessKey { access_key: String, secret_key: String, session_token: Option<String> },
OidcToken { access_token: String, refresh_token: Option<String>, expires_at: Option<u64> },
Custom { scheme: String, params: HashMap<String, String> },
}
```
The trait is intentionally narrow. It returns credentials for a named service.
It does not try to abstract the auth mechanism itself — that stays with the
service wrapper that knows the protocol (S3 signing, OAuth2 refresh, etc.).
Phase 1 provides `SecretStoreCredentialProvider` (reads from
`SecretProtocol::Decrypt`, holds in RAM). Phase 2+ adds
`ManagedCredentialProvider` (with `CredentialManager` for lifecycle management:
refresh, expiration, provisioning).
`CredentialProvider` does not depend on `IdentityProvider`, though
`ManagedCredentialProvider` may use `Identity.id` for identity-bound credential
lookups.
## Consequences
**Positive**: Outbound auth has a unified abstraction, just as inbound auth
has `IdentityProvider`. Service wrappers retrieve credentials through one
interface. `OperationEnv` can expose credentials through `context.env`.
**Positive**: The `CredentialSet` enum covers all identified credential types
(API keys, bearer tokens, S3 access keys, OIDC tokens, basic auth, custom).
This is sufficient for Phases A-C. Phase D (alknet as OIDC provider) is additive.
**Positive**: The trait in core, impl in service crate pattern is consistent
with `IdentityProvider` (trait in core, `ConfigIdentityProvider` in core,
`StorageIdentityProvider` in alknet-storage).
**Negative**: Adds a new core type and a new module (`credentials`). But this
is the same pattern as `IdentityProvider` and `auth` — a small, narrow trait
with a clear contract.
**Negative**: `ManagedCredentialProvider` and `CredentialManager` are Phase C
concepts. The spec should define them as future extensions, not implement them
now.
## References
- ADR-029 (Identity as core type — same pattern)
- [credentials.md](../credentials.md) — CredentialProvider spec
- [research/phase2/credential-provider.md](../../research/phase2/credential-provider.md) — Full analysis
- [identity.md](../identity.md) — IdentityProvider (inbound, opposite direction)

View File

@@ -0,0 +1,83 @@
# ADR-037: API Keys as DynamicConfig Auth
## Status
Accepted
## Context
Alknet's token auth uses Ed25519-signed `AuthToken`s — the same key material
used for SSH auth. This is appropriate for interactive clients (browsers, CLI)
that can generate and sign Ed25519 key pairs.
But for service accounts, automation, and simple integrations, Ed25519 key
pairs are inconvenient. A dashboard backend, a CI/CD pipeline, or a monitoring
script needs a simple bearer token that can be stored in an environment variable
or config file without managing cryptographic key pairs.
The HTTP interface (Phase 2+) requires bearer token auth for `Authorization:
Bearer <token>` headers. `AuthToken` works but requires client-side Ed25519
signing. API keys offer a simpler alternative: short bearer tokens verified by
SHA-256 hash lookup, with optional scope restrictions and TTL.
## Decision
Add `[[auth.api_keys]]` section to `DynamicConfig`:
```toml
[[auth.api_keys]]
prefix = "alk_"
hash = "sha256:abc..."
scopes = ["relay:connect", "secrets:derive"]
description = "dashboard service account"
ttl = "30d" # optional
```
`ConfigIdentityProvider::resolve_from_token()` handles both token types:
- If the input starts with the configured prefix (default `alk_`), treat it as
an API key: hash it with SHA-256 and look up the hash in the `api_keys` table.
- Otherwise, treat it as an `AuthToken`: decode, verify Ed25519 signature,
check timestamp, resolve from `authorized_keys`.
Both paths produce the same `Identity` result. In database-backed deployments,
both resolve to the same account UUID.
API keys are stored as SHA-256 hashes (like password hashing — the cleartext
key is never stored, only its hash). The prefix enables O(1) routing between
AuthToken and API key verification without trying both paths.
The full key is provided to the client exactly once (at creation time). Subsequent
verifications only compare hashes.
## Consequences
**Positive**: Simple bearer token auth for HTTP and other non-SSH interfaces.
No cryptographic key management for service accounts. Consistent with industry
practice (Stripe, GitHub, AWS all use prefixed API keys).
**Positive**: Both AuthTokens and API keys go through `resolve_from_token()`.
The caller doesn't need to know which type they're using. This keeps the
authentication layer unified.
**Positive**: Scoped API keys enable fine-grained access control for service
accounts. A monitoring tool gets `["monitoring:read"]`, not full access.
**Negative**: API keys are bearer tokens — anyone who obtains the key has the
associated permissions. The hash storage and optional TTL mitigate but do not
eliminate this risk. Ed25519 AuthTokens remain the preferred auth method for
interactive clients.
**Negative**: API key rotation requires updating `DynamicConfig` (or the
`api_keys` database table). The `ConfigReloadHandle` / `ConfigService` reload
mechanism handles this, but it's a deliberate operation, not automatic.
**Negative**: No rate limiting on API key verification is built into this ADR.
Rate limiting on the HTTP interface is a separate concern.
## References
- ADR-023 (unified auth, shared key material)
- ADR-029 (Identity as core type)
- ADR-030 (static/dynamic config split)
- [auth.md](../auth.md) — Token auth, AuthPolicy, API keys
- [configuration.md](../configuration.md) — DynamicConfig, AuthPolicy
- [research/phase2/interface-model.md](../../research/phase2/interface-model.md) — API keys in config