docs(arch): ADR-030..033 — repo/adapter pattern, PeerEntry, CredentialStore, forwarded-for

Land the storage and auth strategy research (findings.md) as four
accepted ADRs and amend the core and call specs to match:

- ADR-030: PeerEntry and Identity.id decoupling. Replaces
  authorized_fingerprints with peers: Vec<PeerEntry>; Identity.id becomes
  the stable peer_id, decoupled from the rotating fingerprint. Supersedes
  ADR-029 Assumption 1's UUID source (one-way door preserved, source
  changes). Resolves OQ-33 and the storage-boundary half of OQ-34. Records
  the API-key asymmetry as deliberate (OQ-35).

- ADR-031: CredentialStore repo trait + InMemoryCredentialStore default
  adapter in core. Second repo trait alongside IdentityProvider. Vault
  encrypts; the store persists the EncryptedData blob; assembly layer
  loads into Capabilities. EncryptedData core mirror includes salt for
  wire-format compat.

- ADR-032: Forwarded-for identity. forwarded_for field on call.requested
  and OperationContext — metadata only, never read by AccessControl::check
  (enforced structurally via the check signature). The from_call handler
  populates it. Wire-format one-way door, folded into the ADR-029
  migration window.

- ADR-033: Storage boundary and repo/adapter pattern. Core defines repo
  traits + in-memory defaults; persistence adapters are separate crates;
  assembly layer wires. Resolves OQ-34. Concrete adapter shapes deferred
  for exploration (OQ-36).

Amends auth.md, config.md, operation-registry.md, client-and-adapters.md,
open-questions.md, README.md, crates/core/README.md. Marks ADR-029
Accepted (Assumption 1 carries the ADR-030 superseded note). Marks the
research findings doc reviewed.
This commit is contained in:
2026-06-27 12:12:25 +00:00
parent 347bff257c
commit f224ea998c
13 changed files with 1307 additions and 144 deletions

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-22-21
last_updated: 2026-06-27
---
# alknet-core
@@ -13,8 +13,8 @@ Core library for ALPN-based protocol dispatch. Every handler crate depends on al
|----------|--------|-------------|
| [core-types.md](core-types.md) | draft | ProtocolHandler trait, HandlerError, Connection, BiStream, StreamError |
| [endpoint.md](endpoint.md) | draft | ALPN router, HandlerRegistry, accept loop, graceful shutdown |
| [auth.md](auth.md) | draft | AuthContext, Identity, IdentityProvider, AuthToken, resolution flow |
| [config.md](config.md) | draft | StaticConfig, DynamicConfig, ArcSwap, ConfigReloadHandle |
| [auth.md](auth.md) | draft | AuthContext, Identity, IdentityProvider, AuthToken, resolution flow, PeerEntry, CredentialStore |
| [config.md](config.md) | draft | StaticConfig, DynamicConfig, ArcSwap, ConfigReloadHandle, AuthPolicy.peers |
## Applicable ADRs
@@ -30,6 +30,9 @@ Core library for ALPN-based protocol dispatch. Every handler crate depends on al
| [010](../../decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Endpoint, HandlerRegistry, accept loop |
| [011](../../decisions/011-authcontext-structure.md) | AuthContext Structure | AuthContext fields and resolution flow |
| [015](../../decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | Per-request identity on OperationContext; admin scope for config reload |
| [030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | PeerEntry and Identity.id Decoupling | `authorized_fingerprints``peers: Vec<PeerEntry>`; `Identity.id` = `peer_id` (stable) |
| [031](../../decisions/031-credentialstore-repo-trait.md) | CredentialStore Repo Trait | Second repo trait in core; `InMemoryCredentialStore` default adapter |
| [033](../../decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Storage Boundary and Repo/Adapter Pattern | Core defines traits + in-memory defaults; persistence adapters are separate crates |
## Relevant Open Questions
@@ -38,6 +41,10 @@ Core library for ALPN-based protocol dispatch. Every handler crate depends on al
| OQ-04 | Dynamic handler registration | resolved (start static) | HandlerRegistry is immutable at startup |
| OQ-05 | Multi-connectivity endpoint | resolved (quinn + iroh) | AlknetEndpoint supports both, both feature-gated |
| OQ-11 | Handler-level auth resolution observability | resolved | Handlers store resolved identity on Connection; two identity scopes (connection-level for observability, per-request for ACL) |
| OQ-33 | PeerId — logical id vs crypto identity | resolved by ADR-030 | `PeerId` = `Identity.id` = `PeerEntry.peer_id` (stable across key rotation) |
| OQ-34 | Persistent peer registry (storage boundary) | resolved by ADR-030+031+033 | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
| OQ-35 | API key identity vs peer identity | resolved (recorded by ADR-030) | The asymmetry between fingerprint and API-key paths is deliberate |
| OQ-36 | Concrete adapter shapes | open (deferred for exploration) | The repo/adapter pattern is committed (ADR-033); concrete adapter shapes are not |
## Key Design Principles

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-21
last_updated: 2026-06-27
---
# Authentication
@@ -91,21 +91,41 @@ The authenticated peer identity. Carries authorization information.
```rust
#[derive(Debug, Clone, PartialEq)]
pub struct Identity {
/// Unique identifier string. Fingerprint, key prefix, or principal name.
/// Stable logical identifier. On the fingerprint path, this is the
/// `PeerEntry.peer_id` (stable across key rotation, ADR-030). On the
/// API-key path, this is the key prefix (changes with the key — see
/// "API keys vs peer entries" below). On the composition path, this
/// is the `CompositionAuthority` label (ADR-022).
pub id: String,
/// Authorization scopes. e.g., ["relay:connect", "secrets:derive"]
pub scopes: Vec<String>,
/// Named resource lists. e.g., {"service": ["gitea", "registry"]}
/// Populated from `PeerEntry.resources` on the fingerprint path
/// (ADR-030), from `CompositionAuthority.resources` on the
/// composition path (ADR-022), and empty on the API-key path.
pub resources: HashMap<String, Vec<String>>,
}
```
This is the same structure as the reference implementation (`alknet-main/crates/alknet-core/src/auth/identity.rs`), minus the russh dependency. The `id` field is ALPN-agnostic:
- SSH key auth: `"SHA256:abc123..."` (key fingerprint)
- API key auth: `"alk_test"` (key prefix)
- Certificate auth: `"username"` (principal name)
- SSH key / TLS cert auth (fingerprint path): the `PeerEntry.peer_id` (ADR-030) — a stable logical name like `"worker-a"`, **not** the fingerprint. The fingerprint is the *credential*; the `peer_id` is the *identity*. Decoupling them means key rotation changes the credential but not the identity, so ACL entries and routing references stay stable.
- API key auth: `"alk_test"` (key prefix) — the prefix IS the identity; rotation = new identity (see "API keys vs peer entries" below).
- Composition path: the `CompositionAuthority` label (ADR-022) — e.g., `"agent-chat"`.
### API keys vs peer entries
The fingerprint and API-key auth paths have different identity semantics, by design (ADR-030):
| Axis | Fingerprint (PeerEntry) | API key (ApiKeyEntry) |
|------|-------------------------|------------------------|
| Identity source | TLS handshake / SSH key | Bearer token in protocol frame |
| Key rotation | Same logical node, new material | New identity (revocation = new key) |
| `Identity.id` | `peer_id` (stable across rotation) | `prefix` (changes with the key) |
| `Identity.resources` | Populated from `PeerEntry.resources` | Empty (resources are composition-only) |
An API key's prefix IS the identity — rotating the key means a new prefix and a new identity, by design (revocation is the rotation mechanism for API keys). Decoupling the API key identity from the prefix would solve a problem API keys don't have: they're bearer tokens, not node identities. The fingerprint path gets the `PeerEntry` treatment because node identity must survive key rotation; the API-key path doesn't because bearer-token identity IS the token. The asymmetry is deliberate, not an oversight — see ADR-030 §"API keys".
## AuthToken
@@ -149,30 +169,32 @@ pub struct ConfigIdentityProvider {
The "Config" prefix indicates that identities are resolved from configuration (as opposed to a database or external service). This reads from `ArcSwap<DynamicConfig>`, which is hot-reloadable — not from `StaticConfig`. An alternative name would be `DynamicConfigIdentityProvider` to make this clearer, but `ConfigIdentityProvider` is consistent with the reference implementation and the naming is unlikely to cause confusion in practice.
How it resolves:
- **Fingerprint**: Look up in `DynamicConfig::auth::authorized_keys_fingerprints`. If found, return `Identity { id: fingerprint, scopes: ["relay:connect"], resources: {} }`.
- **Token**: Parse as UTF-8. If it starts with `alk_`, look up in `DynamicConfig::auth::api_keys` by prefix match + SHA-256 hash. If found and not expired, return `Identity { id: prefix, scopes: entry.scopes, resources: {} }`.
- **Fingerprint**: Look up in `DynamicConfig::auth.peers` for the matching `PeerEntry` (by `fingerprint`). If found and `enabled`, return `Identity { id: peer.peer_id, scopes: peer.scopes, resources: peer.resources }`. The `Identity.id` is the stable `peer_id`, **not** the fingerprint — key rotation changes the fingerprint but not the `peer_id`, so ACL entries and routing references stay stable (ADR-030).
- **Token**: Parse as UTF-8. If it starts with `alk_`, look up in `DynamicConfig::auth.api_keys` by prefix match + SHA-256 hash. If found and not expired, return `Identity { id: prefix, scopes: entry.scopes, resources: {} }`. The `Identity.id` is the key prefix — API key rotation = new identity (see "API keys vs peer entries" above).
> **Resource-scoped ACLs and external identities.** `Identity.resources` is
> populated only by the composition path (`CompositionAuthority::as_identity`,
> ADR-015/022) — never by token or fingerprint resolvers. API keys and
> fingerprints grant **scopes only**; resource-scoped access is an
> internal-composition concern. An `OperationSpec` that declares
> `resource_type`/`resource_action` will return `FORBIDDEN` when the caller
> authenticated via token or fingerprint, because `Identity.resources` is
> empty. This is a documented limitation, not a bug: if a future crate needs
> per-key resource binding, it must earn a dedicated ADR that adds a
> `resources` field to `ApiKeyEntry` and the fingerprint config path, rather
> than silently widening the external-auth contract.
See [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md) for the `PeerEntry` model and the id-fingerprint decoupling rationale.
### Resource-scoped ACLs
`Identity.resources` is populated on three paths:
| Path | Source of `resources` | Use case |
|------|----------------------|----------|
| Fingerprint resolution (`ConfigIdentityProvider`) | `PeerEntry.resources` (ADR-030) | External fingerprint-authenticated callers with per-peer resource binding |
| API key resolution (`ConfigIdentityProvider`) | Empty (by design) | API keys grant scopes only; resource-scoped access is composition-only |
| Composition (`CompositionAuthority::as_identity`, ADR-015/022) | `CompositionAuthority.resources` | Internal composition calls with declared resource binding |
An `OperationSpec` that declares `resource_type`/`resource_action` will return `FORBIDDEN` when the caller authenticated via API key (because `Identity.resources` is empty), but succeeds when the caller authenticated via fingerprint with matching `PeerEntry.resources`, or via composition with matching `CompositionAuthority.resources`. The API-key limitation is deliberate (see "API keys vs peer entries" above); the fingerprint path's resource binding is the ADR-030 change that lifts the pre-ADR-030 limitation.
Changes to `DynamicConfig` via `ConfigReloadHandle` are reflected immediately — `ConfigIdentityProvider` reads from `ArcSwap` on every call.
### Fingerprint string format
`tls_client_fingerprint` and `authorized_fingerprints` use a prefixed-hex
`tls_client_fingerprint` and `PeerEntry.fingerprint` use a prefixed-hex
format. The prefix identifies the key type; the body is the hex-encoded
hash or raw key bytes. `AuthPolicy::resolve_identity_from_fingerprint`
does a literal `HashSet::contains()` — no normalization — so the extractor
and the operator config must use the same format.
scans `peers` for a matching `fingerprint` field — no normalization — so
the extractor and the operator config must use the same format.
| Transport | Source | Format |
|-----------|--------|--------|
@@ -196,10 +218,10 @@ normally with `tls_client_fingerprint: None`.
The verifier accepts any presented cert without CA verification because
alknet's identity model is fingerprint-based, not PKI-based — the
`AuthPolicy::authorized_fingerprints` set is the trust anchor, not a
root CA store. The cert bytes are extracted at the TLS layer and hashed
to a fingerprint string; the fingerprint is then matched against the
configured set by `IdentityProvider::resolve_from_fingerprint()`.
`AuthPolicy::peers` set is the trust anchor, not a root CA store. The
cert bytes are extracted at the TLS layer and hashed to a fingerprint
string; the fingerprint is then matched against the configured `PeerEntry.fingerprint`
fields by `IdentityProvider::resolve_from_fingerprint()`.
## Resolution Flow
@@ -293,10 +315,13 @@ The endpoint's `AlknetEndpoint` also holds `Arc<dyn IdentityProvider>` for endpo
| AuthContext is immutable in handle() | [ADR-011](../../decisions/011-authcontext-structure.md) | Handlers create local variables for resolved identity |
| Two resolution paths | [ADR-004](../../decisions/004-auth-as-shared-core.md) | Fingerprint and token, not phased auth |
| Handler stores resolved identity on Connection | OQ-11 (resolved) | `connection.set_identity()` — write-once-read-many for observability |
| PeerEntry and Identity.id decoupling | [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | `authorized_fingerprints``peers: Vec<PeerEntry>`; `Identity.id` = `peer_id` (stable), not fingerprint; key rotation changes fingerprint, not identity |
| CredentialStore repo trait | [ADR-031](../../decisions/031-credentialstore-repo-trait.md) | Second repo trait in core (alongside `IdentityProvider`); `InMemoryCredentialStore` default adapter |
| Storage boundary and repo/adapter pattern | [ADR-033](../../decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Core defines traits + in-memory defaults; persistence adapters are separate crates |
## Open Questions
None. All auth-related open questions are resolved.
- **OQ-35**: API key identity vs peer identity — the asymmetry between the fingerprint path (gets `PeerEntry` id-decoupling) and the API-key path (doesn't) is deliberate. See ADR-030 §"API keys" and "API keys vs peer entries" above.
## Security Constraints

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-22-21
last_updated: 2026-06-27
---
# Configuration
@@ -165,27 +165,78 @@ pub struct DynamicConfig {
### AuthPolicy
Authorization policy derived from authorized keys, certificate authorities, and API keys.
Authorization policy derived from peer entries and API keys.
```rust
pub struct AuthPolicy {
/// SHA-256 fingerprints of authorized keys (SSH keys, TLS client certs).
/// Stored as strings to avoid russh dependency in core.
pub authorized_fingerprints: HashSet<String>,
/// Peer entries: each maps a stable logical peer_id to its current
/// fingerprint, scopes, resources, and enabled state. Replaces the
/// pre-ADR-030 `authorized_fingerprints: HashSet<String>`. The list
/// is keyed by `peer_id`; resolution looks up by `fingerprint`.
/// See ADR-030.
pub peers: Vec<PeerEntry>,
/// API keys for token-based auth.
/// API keys for token-based auth. Unchanged by ADR-030 — API keys
/// don't get the PeerEntry treatment (rotation = new identity is the
/// correct semantics for bearer tokens). See ADR-030 §"API keys".
pub api_keys: Vec<ApiKeyEntry>,
}
```
Certificate authority entries for cert-based auth will be added when
alknet-ssh is implemented. The `cert_authorities` field is omitted from v1
to avoid referencing an undefined type. Adding it back is additive (a new
### PeerEntry
A peer entry maps a stable logical peer identity to its current
cryptographic material and authorization scopes. The `peer_id` is stable
across key rotation; the `fingerprint` changes when the node rotates its
TLS key. `ConfigIdentityProvider::resolve_from_fingerprint` resolves
fingerprint → `PeerEntry``Identity { id: peer_id, ... }`, so
`Identity.id` is the stable `peer_id`, not the rotating fingerprint.
```rust
pub struct PeerEntry {
/// Stable logical peer id ("worker-a", "alice"). Does NOT change on
/// key rotation. This becomes Identity.id on resolution.
pub peer_id: String,
/// Current cryptographic material — the fingerprint the endpoint
/// extracts from the TLS handshake (SHA256:... for X.509, ed25519:...
/// for RFC 7250 raw keys). Changes on key rotation.
pub fingerprint: String,
/// Authorization scopes granted to this peer. Resolved into
/// Identity.scopes.
pub scopes: Vec<String>,
/// Named resource lists granted to this peer. Resolved into
/// Identity.resources. Populated from config (ADR-030 lifts the
/// pre-ADR-030 limitation that fingerprint-resolved identities had
/// empty resources).
pub resources: HashMap<String, Vec<String>>,
/// Human-readable display name for logs / UIs. Optional.
pub display_name: Option<String>,
/// Whether this peer is authorized at all. false = the fingerprint
/// is recognized but the peer is disabled (token-revoked-equivalent
/// for fingerprints). Resolution returns None.
pub enabled: bool,
}
```
See [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md)
for the `PeerEntry` model, the id-fingerprint decoupling rationale, and
the key-rotation story (vault rotates locally; the remote side updates
the `PeerEntry.fingerprint` field; the `peer_id` and all ACL / routing
references stay stable).
Certificate authority entries for cert-based auth are omitted from
`AuthPolicy` until alknet-ssh is implemented, to avoid referencing an
undefined type. Adding the `cert_authorities` field is additive (a new
field on `AuthPolicy` is non-breaking for existing config files that don't
use it). alknet-ssh will define `CertAuthorityEntry` with the necessary
fields (public key, principals, options).
This replaces the reference implementation's `AuthPolicy` which depended on `russh::keys::PublicKey`. The new version stores fingerprints as strings, not russh types. This removes the russh dependency from alknet-core.
This replaces the reference implementation's `AuthPolicy` which depended on `russh::keys::PublicKey`. The new version stores fingerprints as strings (in `PeerEntry.fingerprint`), not russh types. This removes the russh dependency from alknet-core.
### ApiKeyEntry
@@ -286,4 +337,6 @@ Simplified from the reference implementation. Removes proxy-specific errors (now
|----------|-----|---------|
| No russh dependency in core | [ADR-003](../../decisions/003-crate-decomposition.md) | Core is ALPN-agnostic; russh is an alknet-ssh dependency |
| ArcSwap for dynamic config | Carry-forward from reference | Lock-free reads, atomic swaps |
| No ListenerConfig | [ADR-001](../../decisions/001-alpn-protocol-dispatch.md) | Single endpoint, ALPN replaces multiple listener types |
| No ListenerConfig | [ADR-001](../../decisions/001-alpn-protocol-dispatch.md) | Single endpoint, ALPN replaces multiple listener types |
| PeerEntry and Identity.id decoupling | [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | `authorized_fingerprints: HashSet<String>``peers: Vec<PeerEntry>`; `Identity.id` = `peer_id` (stable), not fingerprint |
| Storage boundary and repo/adapter pattern | [ADR-033](../../decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Core defines repo traits + in-memory defaults; `AuthPolicy.peers` is the config model for the in-memory `ConfigIdentityProvider` adapter; persistence adapters are separate crates |