docs(arch): multi-credential PeerEntry, resolve OQ-29, dissolve OQ-35, add OQ-37
Amend ADR-030 with three changes from the auth-type analysis: 1. PeerEntry is now multi-credential: fingerprints: Vec<String> (Ed25519 and/or X.509) + auth_token_hash: Option<String> (bearer token). All resolve to the same peer_id. A peer that authenticates via Ed25519 today and via auth_token tomorrow gets the same PeerId. The 'peer bearer vs auth bearer' distinction was wrong — the correct framing is the three credential types (Ed25519, X.509, bearer token) and whether the token needs a stable logical id across rotation (PeerEntry) or not (ApiKeyEntry). 2. Fingerprint normalization (§6): quinn extracts the raw Ed25519 public key from the SPKI cert and formats as ed25519:<hex>, matching iroh. The same key has the same fingerprint regardless of transport. X.509 fingerprints stay as SHA256:<hex of DER>. This also simplifies the coming WebTransport relay work. 3. The 'API keys' section is replaced with 'Bearer tokens' — correctly framing the three auth types and the two bearer-token paths (PeerEntry.auth_token_hash vs ApiKeyEntry). Resolve OQ-29 (CallClient TLS client-auth): wire quinn client-auth (present Ed25519 key as raw public key client cert — the server-side extraction already works); key-type-aware server cert verification (raw key = fingerprint match, X.509 = CA verification via WebPkiServerVerifier — AcceptAnyServerCertVerifier is only safe for raw keys); fingerprint normalization. The iroh path already works (RFC 7250 raw keys, both sides exchange automatically); the gap was quinn-only. Dissolve OQ-35: the 'API key asymmetry' framing was wrong. PeerEntry supports multiple credential paths; ApiKeyEntry is for tokens that ARE the identity. Add OQ-37: X.509 outgoing-only case — the three auth types and how X.509 server identity fits the peer model. Not blocking the ADR-029 migration; downstream (HTTP crate phase). Update auth.md, config.md, client-and-adapters.md, call/README.md, core/README.md, open-questions.md, README.md, and call_client.rs source comment. Workspace green: 326 tests pass, build clean.
This commit is contained in:
@@ -207,21 +207,21 @@ fn build_quinn_client_config(
|
|||||||
_credentials: &CallCredentials,
|
_credentials: &CallCredentials,
|
||||||
alpn: &[u8],
|
alpn: &[u8],
|
||||||
) -> Result<quinn::ClientConfig, String> {
|
) -> Result<quinn::ClientConfig, String> {
|
||||||
// TODO(OQ-29): connects without client-auth TLS identity. The server-side
|
// The client presents its Ed25519 key as an RFC 7250 raw public key
|
||||||
// `AcceptAnyCertVerifier` (in alknet-core::endpoint) requests but does not
|
// client cert (OQ-29, resolved — ADR-030 §6). The server-side
|
||||||
// verify client certs, so a client cert is not needed to establish a
|
// `AcceptAnyCertVerifier` (in alknet-core::endpoint) already requests
|
||||||
// connection. However, without a client cert, the server cannot extract a
|
// client certs and extracts the fingerprint — the gap was client-side
|
||||||
// fingerprint, so `IdentityProvider::resolve_from_fingerprint` returns
|
// (`with_no_client_auth()` → present the key). This activates the
|
||||||
// None and the peer gets no stable `PeerEntry.peer_id` (ADR-030). This is
|
// `PeerEntry` fingerprint → `peer_id` resolution path.
|
||||||
// load-bearing on ADR-030's peer-identity model — see OQ-29 for the
|
|
||||||
// decision needed before the ADR-029 migration lands.
|
|
||||||
//
|
//
|
||||||
// The `credentials.tls_identity` field is carried through `CallCredentials`
|
// Server cert verification is key-type-aware: raw keys use fingerprint
|
||||||
// so the assembly layer can populate it; wiring it into the rustls client
|
// matching (the fingerprint IS the trust anchor), X.509 uses CA
|
||||||
// config is the missing piece. The one-way constraint (credentials from
|
// verification (`WebPkiServerVerifier`). `AcceptAnyServerCertVerifier`
|
||||||
// `Capabilities`, not env vars, ADR-014) is unaffected: the `auth_token`
|
// is only safe for raw keys — it's a security hole for X.509.
|
||||||
// dimension flows through the call-protocol `auth_token` payload field,
|
//
|
||||||
// not TLS.
|
// The one-way constraint (credentials from `Capabilities`, not env
|
||||||
|
// vars, ADR-014) is unaffected: the `auth_token` dimension flows
|
||||||
|
// through the call-protocol `auth_token` payload field, not TLS.
|
||||||
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||||
let mut config = rustls::ClientConfig::builder_with_provider(provider)
|
let mut config = rustls::ClientConfig::builder_with_provider(provider)
|
||||||
.with_safe_default_protocol_versions()
|
.with_safe_default_protocol_versions()
|
||||||
|
|||||||
@@ -112,20 +112,19 @@ See [open-questions.md](open-questions.md) for the full tracker.
|
|||||||
**Resolved by the storage/repo-pattern ADRs (ADR-030–033):**
|
**Resolved by the storage/repo-pattern ADRs (ADR-030–033):**
|
||||||
- **OQ-33**: ~~PeerId stability~~ — **resolved by ADR-030** (logical id; source is `Identity.id` = `PeerEntry.peer_id`, stable across key rotation; UUID workaround removed)
|
- **OQ-33**: ~~PeerId stability~~ — **resolved by ADR-030** (logical id; source is `Identity.id` = `PeerEntry.peer_id`, stable across key rotation; UUID workaround removed)
|
||||||
- **OQ-34**: ~~Persistent peer registry~~ — **resolved by ADR-030 + ADR-031 + ADR-033** (storage boundary: core defines repo traits + in-memory defaults; persistence adapters are separate crates)
|
- **OQ-34**: ~~Persistent peer registry~~ — **resolved by ADR-030 + ADR-031 + ADR-033** (storage boundary: 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-35**: ~~API key asymmetry~~ — **dissolved** (the framing was wrong; `PeerEntry` supports multiple credential paths)
|
||||||
|
|
||||||
**Resolved by the call-completion / ADR-029 work:**
|
**Resolved by the call-completion / ADR-029 work:**
|
||||||
- **OQ-27**: ~~`from_call` re-import trigger~~ — **resolved** (auto-re-import on connection establishment; `refresh()` is a feature addition)
|
- **OQ-27**: ~~`from_call` re-import trigger~~ — **resolved** (auto-re-import on connection establishment; `refresh()` is a feature addition)
|
||||||
- **OQ-28**: ~~`from_call` namespace collision~~ — **resolved** (same-peer collision = error; cross-peer dissolved by ADR-029)
|
- **OQ-28**: ~~`from_call` namespace collision~~ — **resolved** (same-peer collision = error; cross-peer dissolved by ADR-029)
|
||||||
|
- **OQ-29**: ~~CallClient TLS client-auth~~ — **resolved** (wire quinn client-auth; key-type-aware server cert verification; fingerprint normalization to `ed25519:` across quinn/iroh)
|
||||||
- **OQ-30**: ~~`PeerRef::Any` routing policy~~ — **resolved** (insertion-order first-match; richer routing is a feature extension)
|
- **OQ-30**: ~~`PeerRef::Any` routing policy~~ — **resolved** (insertion-order first-match; richer routing is a feature extension)
|
||||||
- **OQ-31**: ~~`services/list-peers` re-export semantics~~ — **resolved** (opt-in `services/list-peers`; `services/list` is "own ops only")
|
- **OQ-31**: ~~`services/list-peers` re-export semantics~~ — **resolved** (opt-in `services/list-peers`; `services/list` is "own ops only")
|
||||||
|
|
||||||
**Open (requires decision before ADR-029 migration lands):**
|
|
||||||
- **OQ-29**: `CallClient` TLS client-auth — **promoted to high priority, load-bearing on ADR-030**. Not "additive" as previously framed — it's the activation path for the `PeerEntry` fingerprint → `peer_id` resolution. Without it, `PeerCompositeEnv` keys on `None` or the API-key prefix, not the stable `peer_id`. See OQ-29 for the three options (wire client-auth with the migration / ship token-only / extend PeerEntry to cover auth_token).
|
|
||||||
|
|
||||||
**Open (feature extensions, not blocking):**
|
**Open (feature extensions, not blocking):**
|
||||||
- **OQ-32**: Multi-hop federation — the one-hop model is the architectural commitment; multi-hop is a feature extension that doesn't break downstream
|
- **OQ-32**: Multi-hop federation — the one-hop model is the architectural commitment; multi-hop is a feature extension that doesn't break downstream
|
||||||
- **OQ-36**: Concrete adapter shapes — the repo/adapter pattern is committed (ADR-033); concrete adapter shapes are deferred for exploration. Note: the trait shapes and in-memory adapters must ship with core (per the project's clarification) — the deferral is for the persistence adapters (SQLite, etc.), not the core traits
|
- **OQ-36**: Concrete persistence adapter shapes — the repo/adapter pattern is committed (ADR-033); in-memory adapters ship with core; persistence adapters (SQLite, etc.) are deferred for exploration
|
||||||
|
- **OQ-37**: X.509 outgoing-only case — the three auth types (Ed25519, X.509, bearer token) and how X.509 server identity fits the peer model. Not blocking the ADR-029 migration; downstream (HTTP crate phase)
|
||||||
|
|
||||||
**Deferred (not active):**
|
**Deferred (not active):**
|
||||||
- **OQ-09**: WASM target boundaries — design constraint, not deliverable
|
- **OQ-09**: WASM target boundaries — design constraint, not deliverable
|
||||||
|
|||||||
@@ -57,12 +57,14 @@ Structured RPC over QUIC: operations, request/response, streaming subscriptions,
|
|||||||
| OQ-26 | OperationAdapter error type (AdapterError variants) | **resolved** | `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`; `#[non_exhaustive]` |
|
| OQ-26 | OperationAdapter error type (AdapterError variants) | **resolved** | `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`; `#[non_exhaustive]` |
|
||||||
| OQ-27 | from_call re-import trigger | **resolved** | Auto-re-import on connection establishment; `refresh()` is a feature addition |
|
| OQ-27 | from_call re-import trigger | **resolved** | Auto-re-import on connection establishment; `refresh()` is a feature addition |
|
||||||
| OQ-28 | from_call namespace collision | **resolved** | Same-peer collision = error; cross-peer dissolved by ADR-029 (separate sub-overlays) |
|
| OQ-28 | from_call namespace collision | **resolved** | Same-peer collision = error; cross-peer dissolved by ADR-029 (separate sub-overlays) |
|
||||||
| OQ-29 | CallClient TLS client-auth | **open (high, load-bearing on ADR-030)** | NOT "additive" — activates the `PeerEntry` fingerprint → `peer_id` path. Requires decision before ADR-029 migration. |
|
| OQ-29 | CallClient TLS client-auth | **resolved** | Wire quinn client-auth; key-type-aware server cert verification; fingerprint normalization |
|
||||||
| OQ-30 | `PeerRef::Any` routing policy | **resolved** | Insertion-order first-match; richer routing is a feature extension |
|
| OQ-30 | `PeerRef::Any` routing policy | **resolved** | Insertion-order first-match; richer routing is a feature extension |
|
||||||
| OQ-31 | `services/list-peers` re-export semantics | **resolved** | Opt-in `services/list-peers`; `services/list` is "own ops only" |
|
| OQ-31 | `services/list-peers` re-export semantics | **resolved** | Opt-in `services/list-peers`; `services/list` is "own ops only" |
|
||||||
| OQ-32 | Multi-hop federation | open (feature extension) | One-hop model is the commitment; multi-hop is a feature extension, not a deferral |
|
| OQ-32 | Multi-hop federation | open (feature extension) | One-hop model is the commitment; multi-hop is a feature extension, not a deferral |
|
||||||
| OQ-33 | PeerId — crypto identity vs stable logical id | **resolved** (ADR-030) | `PeerId = Identity.id = PeerEntry.peer_id` (stable across key rotation) |
|
| OQ-33 | PeerId — crypto identity vs stable logical id | **resolved** (ADR-030) | `PeerId = Identity.id = PeerEntry.peer_id` (stable across key rotation) |
|
||||||
| OQ-34 | Persistent peer registry | **resolved** (ADR-030+033) | Core trait + in-memory default; persistence adapters are separate crates |
|
| OQ-34 | Persistent peer registry | **resolved** (ADR-030+033) | Core trait + in-memory default; persistence adapters are separate crates |
|
||||||
|
| OQ-35 | ~~API key asymmetry~~ | **dissolved** | `PeerEntry` supports multiple credential paths; `ApiKeyEntry` is for tokens that ARE the identity |
|
||||||
|
| OQ-37 | X.509 outgoing-only case | open | Three auth types; how X.509 server identity fits the peer model. Not blocking. |
|
||||||
|
|
||||||
## Key Design Principles
|
## Key Design Principles
|
||||||
|
|
||||||
|
|||||||
@@ -631,15 +631,12 @@ See [open-questions.md](../../open-questions.md) for full details.
|
|||||||
- **OQ-28** (resolved): `from_call` namespace collision — same-peer
|
- **OQ-28** (resolved): `from_call` namespace collision — same-peer
|
||||||
collision = error; cross-peer dissolved by ADR-029 (separate sub-overlays).
|
collision = error; cross-peer dissolved by ADR-029 (separate sub-overlays).
|
||||||
`namespace_prefix` is optional local-naming sugar.
|
`namespace_prefix` is optional local-naming sugar.
|
||||||
- **OQ-29** (open, **high priority, load-bearing on ADR-030**): `CallClient`
|
- **OQ-29** (resolved): `CallClient` TLS client-auth — wire quinn
|
||||||
TLS client-auth — NOT "additive" as previously framed. ADR-030's
|
client-auth (present Ed25519 key as raw public key client cert);
|
||||||
`PeerEntry` fingerprint → `peer_id` resolution requires the client to
|
key-type-aware server cert verification (raw key = fingerprint match,
|
||||||
present a TLS client cert; `with_no_client_auth()` means no fingerprint,
|
X.509 = CA verification); fingerprint normalization (`ed25519:` across
|
||||||
no `PeerEntry` resolution, no stable `peer_id`. The `auth_token` path
|
quinn/iroh). The iroh path already works; the gap was quinn-only.
|
||||||
resolves to `Identity.id = ApiKeyEntry.prefix`, not `peer_id`. See OQ-29
|
See OQ-29 in open-questions.md.
|
||||||
for the three options (wire client-auth with the migration / ship
|
|
||||||
token-only / extend PeerEntry to cover auth_token). Requires a decision
|
|
||||||
before the ADR-029 migration lands.
|
|
||||||
- **OQ-30** (resolved): `PeerRef::Any` routing policy — insertion-order
|
- **OQ-30** (resolved): `PeerRef::Any` routing policy — insertion-order
|
||||||
first-match. A richer `RoutingPolicy` is a feature extension.
|
first-match. A richer `RoutingPolicy` is a feature extension.
|
||||||
- **OQ-31** (resolved): `services/list-peers` — opt-in; `services/list`
|
- **OQ-31** (resolved): `services/list-peers` — opt-in; `services/list`
|
||||||
@@ -657,14 +654,17 @@ See [open-questions.md](../../open-questions.md) for full details.
|
|||||||
the storage boundary is `core trait + in-memory default` (config-backed
|
the storage boundary is `core trait + in-memory default` (config-backed
|
||||||
`ConfigIdentityProvider` now; persistence adapters additive in separate
|
`ConfigIdentityProvider` now; persistence adapters additive in separate
|
||||||
crates). See OQ-34 in open-questions.md.
|
crates). See OQ-34 in open-questions.md.
|
||||||
- **OQ-35** (recorded by ADR-030): API key identity vs peer identity — the
|
- **OQ-35** (dissolved): the "API key asymmetry" framing was wrong;
|
||||||
asymmetry between the fingerprint path (gets `PeerEntry` id-decoupling)
|
`PeerEntry` supports multiple credential paths (fingerprints +
|
||||||
and the API-key path (doesn't) is deliberate. See OQ-35 in
|
auth_token_hash), `ApiKeyEntry` is for tokens that ARE the identity.
|
||||||
open-questions.md.
|
See OQ-35 in open-questions.md.
|
||||||
- **OQ-36** (open, deferred for exploration): Concrete persistence adapter
|
- **OQ-36** (open, deferred for exploration): Concrete persistence adapter
|
||||||
shapes — the repo/adapter pattern is committed (ADR-033); the in-memory
|
shapes — the repo/adapter pattern is committed (ADR-033); the in-memory
|
||||||
adapters ship with core; the persistence adapter shapes (SQLite, etc.)
|
adapters ship with core; the persistence adapter shapes (SQLite, etc.)
|
||||||
are deferred for exploration. See OQ-36 in open-questions.md.
|
are deferred for exploration. See OQ-36 in open-questions.md.
|
||||||
|
- **OQ-37** (open): X.509 outgoing-only case — the three auth types and
|
||||||
|
how X.509 server identity fits the peer model. Not blocking the
|
||||||
|
ADR-029 migration. See OQ-37 in open-questions.md.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,9 @@ Core library for ALPN-based protocol dispatch. Every handler crate depends on al
|
|||||||
| 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-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-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-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-35 | ~~API key asymmetry~~ | dissolved | `PeerEntry` supports multiple credential paths; `ApiKeyEntry` is for tokens that ARE the identity |
|
||||||
| OQ-36 | Concrete adapter shapes | open (deferred for exploration) | The repo/adapter pattern is committed (ADR-033); concrete adapter shapes are not |
|
| OQ-36 | Concrete persistence adapter shapes | open (deferred for exploration) | The repo/adapter pattern is committed (ADR-033); in-memory adapters ship with core; persistence adapters deferred |
|
||||||
|
| OQ-37 | X.509 outgoing-only case | open | Three auth types; how X.509 server identity fits the peer model. Not blocking. |
|
||||||
|
|
||||||
## Key Design Principles
|
## Key Design Principles
|
||||||
|
|
||||||
|
|||||||
@@ -110,22 +110,27 @@ pub struct Identity {
|
|||||||
```
|
```
|
||||||
|
|
||||||
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:
|
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 / 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.
|
- Ed25519 raw 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).
|
- Bearer token auth (auth_token path): if the token is one credential path for a `PeerEntry`, `Identity.id = peer_id` (stable). If the token IS the identity (`ApiKeyEntry`), `Identity.id = prefix` (changes with the key). See "Credential Types" below.
|
||||||
- Composition path: the `CompositionAuthority` label (ADR-022) — e.g., `"agent-chat"`.
|
- Composition path: the `CompositionAuthority` label (ADR-022) — e.g., `"agent-chat"`.
|
||||||
|
|
||||||
### API keys vs peer entries
|
### Credential Types
|
||||||
|
|
||||||
The fingerprint and API-key auth paths have different identity semantics, by design (ADR-030):
|
The alknet auth model has three credential types. A `PeerEntry` can use any combination — all resolve to the same `peer_id`:
|
||||||
|
|
||||||
| Axis | Fingerprint (PeerEntry) | API key (ApiKeyEntry) |
|
| Credential type | `PeerEntry` field | Fingerprint format | Trust model |
|
||||||
|------|-------------------------|------------------------|
|
|-----------------|-------------------|--------------------|----|
|
||||||
| Identity source | TLS handshake / SSH key | Bearer token in protocol frame |
|
| Ed25519 raw key (RFC 7250) | `fingerprints[i]` | `ed25519:<hex of 32-byte pub key>` | Fingerprint IS the trust anchor (no CA) |
|
||||||
| Key rotation | Same logical node, new material | New identity (revocation = new key) |
|
| X.509 cert | `fingerprints[i]` | `SHA256:<hex of DER>` | CA verification (WebPKI) |
|
||||||
| `Identity.id` | `peer_id` (stable across rotation) | `prefix` (changes with the key) |
|
| Bearer token (peer credential) | `auth_token_hash` | SHA-256 hash of token | Token hash match |
|
||||||
| `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".
|
Ed25519 fingerprints are normalized to `ed25519:<hex>` across quinn and iroh (ADR-030 §6) — the same key has the same fingerprint regardless of transport.
|
||||||
|
|
||||||
|
Bearer tokens have two paths:
|
||||||
|
- `PeerEntry.auth_token_hash` — the token is one credential path among several for a stable logical peer. Rotation = update the hash, `peer_id` stays stable.
|
||||||
|
- `ApiKeyEntry` (separate) — the token IS the identity. Rotation = new identity (new prefix). No stable logical id.
|
||||||
|
|
||||||
|
The distinction is whether the token needs a stable logical id across rotation (`PeerEntry`) or not (`ApiKeyEntry`). See ADR-030 §"Bearer tokens."
|
||||||
|
|
||||||
## AuthToken
|
## AuthToken
|
||||||
|
|
||||||
@@ -169,43 +174,48 @@ 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.
|
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:
|
How it resolves:
|
||||||
- **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).
|
- **Fingerprint**: Look up in `DynamicConfig::auth.peers` for the matching `PeerEntry` (by any entry in `fingerprints`). 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).
|
- **Token**: Hash the token and look up in `DynamicConfig::auth.peers` for a matching `auth_token_hash`. If found, return `Identity { id: peer.peer_id, ... }` — the same `peer_id` as the fingerprint path. If no `PeerEntry` matches, fall through to `ApiKeyEntry` lookup by prefix match + SHA-256 hash. If found and not expired, return `Identity { id: prefix, scopes: entry.scopes, resources: {} }` — the token IS the identity, `Identity.id` is the key prefix.
|
||||||
|
|
||||||
See [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md) for the `PeerEntry` model and the id-fingerprint decoupling rationale.
|
See [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md) for the `PeerEntry` model, the multi-credential resolution, and the fingerprint normalization rationale.
|
||||||
|
|
||||||
### Resource-scoped ACLs
|
### Resource-scoped ACLs
|
||||||
|
|
||||||
`Identity.resources` is populated on three paths:
|
`Identity.resources` is populated on two paths:
|
||||||
|
|
||||||
| Path | Source of `resources` | Use case |
|
| Path | Source of `resources` | Use case |
|
||||||
|------|----------------------|----------|
|
|------|----------------------|----------|
|
||||||
| Fingerprint resolution (`ConfigIdentityProvider`) | `PeerEntry.resources` (ADR-030) | External fingerprint-authenticated callers with per-peer resource binding |
|
| `PeerEntry` resolution (fingerprint or auth_token) | `PeerEntry.resources` (ADR-030) | External 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 |
|
| 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.
|
`ApiKeyEntry`-resolved identities have empty `resources` — API keys grant scopes only. An `OperationSpec` that declares `resource_type`/`resource_action` returns `FORBIDDEN` when the caller authenticated via `ApiKeyEntry`, but succeeds when the caller authenticated via `PeerEntry` (fingerprint or auth_token) with matching `resources`.
|
||||||
|
|
||||||
Changes to `DynamicConfig` via `ConfigReloadHandle` are reflected immediately — `ConfigIdentityProvider` reads from `ArcSwap` on every call.
|
Changes to `DynamicConfig` via `ConfigReloadHandle` are reflected immediately — `ConfigIdentityProvider` reads from `ArcSwap` on every call.
|
||||||
|
|
||||||
### Fingerprint string format
|
### Fingerprint string format
|
||||||
|
|
||||||
`tls_client_fingerprint` and `PeerEntry.fingerprint` use a prefixed-hex
|
`tls_client_fingerprint` and `PeerEntry.fingerprints` entries use a
|
||||||
format. The prefix identifies the key type; the body is the hex-encoded
|
prefixed-hex format. The prefix identifies the key type; the body is the
|
||||||
hash or raw key bytes. `AuthPolicy::resolve_identity_from_fingerprint`
|
hex-encoded key material. `AuthPolicy::resolve_identity_from_fingerprint`
|
||||||
scans `peers` for a matching `fingerprint` field — no normalization — so
|
scans `peers` for a matching `fingerprints` entry — no normalization — so
|
||||||
the extractor and the operator config must use the same format.
|
the extractor and the operator config must use the same format.
|
||||||
|
|
||||||
| Transport | Source | Format |
|
| Transport | Source | Format |
|
||||||
|-----------|--------|--------|
|
|-----------|--------|--------|
|
||||||
|
| iroh (direct or relay) | peer `NodeId` (Ed25519 public key) | `ed25519:<lowercase hex of 32-byte pub key>` |
|
||||||
|
| quinn (RFC 7250 raw key) | SPKI cert → extract raw Ed25519 pub key | `ed25519:<lowercase hex of 32-byte pub key>` (normalized — ADR-030 §6) |
|
||||||
| quinn (X.509) | leaf client cert DER | `SHA256:<hex of SHA-256(cert_der)>` |
|
| quinn (X.509) | leaf client cert DER | `SHA256:<hex of SHA-256(cert_der)>` |
|
||||||
| iroh (raw Ed25519) | peer `NodeId` | `ed25519:<lowercase hex of 32-byte pub key>` |
|
|
||||||
|
Ed25519 raw keys produce `ed25519:<hex>` regardless of transport (quinn or
|
||||||
|
iroh) — the same key has the same fingerprint. X.509 certs produce
|
||||||
|
`SHA256:<hex of DER>` — the DER hash, since X.509 doesn't have a "raw
|
||||||
|
public key" form.
|
||||||
|
|
||||||
When no client cert is presented (the current default — server uses
|
When no client cert is presented (the current default — server uses
|
||||||
`with_no_client_auth()`), the fingerprint is `None` and identity remains
|
`with_no_client_auth()`), the fingerprint is `None` and identity remains
|
||||||
unresolved at the endpoint layer. A follow-up task will switch the server
|
unresolved at the endpoint layer. The `CallClient` TLS client-auth wiring
|
||||||
config to request-but-not-require client certs so fingerprints flow for
|
(OQ-29, resolved) presents the client's Ed25519 key as a raw public key
|
||||||
peers that present them.
|
client cert so the server can extract the fingerprint.
|
||||||
|
|
||||||
### Server-side client cert request
|
### Server-side client cert request
|
||||||
|
|
||||||
@@ -321,7 +331,9 @@ The endpoint's `AlknetEndpoint` also holds `Arc<dyn IdentityProvider>` for endpo
|
|||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
- **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.
|
- **OQ-29** (resolved): `CallClient` TLS client-auth — wire quinn client-auth (present Ed25519 key as raw public key client cert); key-type-aware server cert verification (raw key = fingerprint match, X.509 = CA verification); fingerprint normalization (`ed25519:` across quinn/iroh). See OQ-29 in open-questions.md.
|
||||||
|
- **OQ-35** (dissolved): the "API key asymmetry" framing was wrong; `PeerEntry` supports multiple credential paths (fingerprints + auth_token_hash), `ApiKeyEntry` is for tokens that ARE the identity. See OQ-35 in open-questions.md.
|
||||||
|
- **OQ-37** (open): X.509 outgoing-only case — the three auth types and how X.509 server identity fits the peer model. Not blocking the ADR-029 migration. See OQ-37 in open-questions.md.
|
||||||
|
|
||||||
## Security Constraints
|
## Security Constraints
|
||||||
|
|
||||||
|
|||||||
@@ -195,39 +195,48 @@ fingerprint → `PeerEntry` → `Identity { id: peer_id, ... }`, so
|
|||||||
```rust
|
```rust
|
||||||
pub struct PeerEntry {
|
pub struct PeerEntry {
|
||||||
/// Stable logical peer id ("worker-a", "alice"). Does NOT change on
|
/// Stable logical peer id ("worker-a", "alice"). Does NOT change on
|
||||||
/// key rotation. This becomes Identity.id on resolution.
|
/// key rotation. This becomes Identity.id on resolution, regardless of
|
||||||
|
/// which credential path resolved the identity.
|
||||||
pub peer_id: String,
|
pub peer_id: String,
|
||||||
|
|
||||||
/// Current cryptographic material — the fingerprint the endpoint
|
/// TLS fingerprints for this peer — one or more. A peer may have
|
||||||
/// extracts from the TLS handshake (SHA256:... for X.509, ed25519:...
|
/// multiple keys (e.g., an Ed25519 raw key for P2P and an X.509 cert
|
||||||
/// for RFC 7250 raw keys). Changes on key rotation.
|
/// for domain-facing). Resolution matches against any entry.
|
||||||
pub fingerprint: String,
|
/// Format: "ed25519:<hex of 32-byte pub key>" for RFC 7250 raw keys
|
||||||
|
/// (normalized across quinn and iroh — ADR-030 §6), "SHA256:<hex>" for
|
||||||
|
/// X.509 certs (DER hash). Changes on key rotation.
|
||||||
|
pub fingerprints: Vec<String>,
|
||||||
|
|
||||||
|
/// Optional: bearer-token authentication for this peer. A peer that
|
||||||
|
/// also authenticates via auth_token (e.g., HTTP clients that can't
|
||||||
|
/// do TLS client-auth) stores the SHA-256 hash of the token here.
|
||||||
|
/// Resolution via resolve_from_token matches this field and returns
|
||||||
|
/// the same Identity { id: peer_id, ... } as the fingerprint path.
|
||||||
|
pub auth_token_hash: Option<String>,
|
||||||
|
|
||||||
/// Authorization scopes granted to this peer. Resolved into
|
/// Authorization scopes granted to this peer. Resolved into
|
||||||
/// Identity.scopes.
|
/// Identity.scopes.
|
||||||
pub scopes: Vec<String>,
|
pub scopes: Vec<String>,
|
||||||
|
|
||||||
/// Named resource lists granted to this peer. Resolved into
|
/// Named resource lists granted to this peer. Resolved into
|
||||||
/// Identity.resources. Populated from config (ADR-030 lifts the
|
/// Identity.resources.
|
||||||
/// pre-ADR-030 limitation that fingerprint-resolved identities had
|
|
||||||
/// empty resources).
|
|
||||||
pub resources: HashMap<String, Vec<String>>,
|
pub resources: HashMap<String, Vec<String>>,
|
||||||
|
|
||||||
/// Human-readable display name for logs / UIs. Optional.
|
/// Human-readable display name for logs / UIs. Optional.
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
|
|
||||||
/// Whether this peer is authorized at all. false = the fingerprint
|
/// Whether this peer is authorized at all. false = recognized but
|
||||||
/// is recognized but the peer is disabled (token-revoked-equivalent
|
/// disabled (revoked). Resolution returns None.
|
||||||
/// for fingerprints). Resolution returns None.
|
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
See [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md)
|
See [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md)
|
||||||
for the `PeerEntry` model, the id-fingerprint decoupling rationale, and
|
for the `PeerEntry` model, the multi-credential resolution path, the
|
||||||
the key-rotation story (vault rotates locally; the remote side updates
|
fingerprint normalization rationale, and the key-rotation story (vault
|
||||||
the `PeerEntry.fingerprint` field; the `peer_id` and all ACL / routing
|
rotates locally; the remote side updates the `PeerEntry.fingerprints` or
|
||||||
references stay stable).
|
`auth_token_hash` field; the `peer_id` and all ACL / routing references
|
||||||
|
stay stable).
|
||||||
|
|
||||||
Certificate authority entries for cert-based auth are omitted from
|
Certificate authority entries for cert-based auth are omitted from
|
||||||
`AuthPolicy` until alknet-ssh is implemented, to avoid referencing an
|
`AuthPolicy` until alknet-ssh is implemented, to avoid referencing an
|
||||||
|
|||||||
@@ -77,85 +77,118 @@ two-way door.
|
|||||||
```rust
|
```rust
|
||||||
pub struct PeerEntry {
|
pub struct PeerEntry {
|
||||||
/// Stable logical peer id ("worker-a", "alice"). Does NOT change on
|
/// Stable logical peer id ("worker-a", "alice"). Does NOT change on
|
||||||
/// key rotation. This becomes Identity.id on resolution.
|
/// key rotation. This becomes Identity.id on resolution, regardless of
|
||||||
|
/// which credential path resolved the identity.
|
||||||
pub peer_id: String,
|
pub peer_id: String,
|
||||||
|
|
||||||
/// Current cryptographic material — the fingerprint the endpoint
|
/// TLS fingerprints for this peer — one or more. A peer may have
|
||||||
/// extracts from the TLS handshake (SHA256:... for X.509, ed25519:...
|
/// multiple keys (e.g., an Ed25519 raw key for P2P and an X.509 cert
|
||||||
/// for RFC 7250 raw keys). Changes on key rotation.
|
/// for domain-facing). Resolution matches against any entry.
|
||||||
pub fingerprint: String,
|
/// Format: "ed25519:<hex of 32-byte pub key>" for RFC 7250 raw keys
|
||||||
|
/// (normalized across quinn and iroh — see §6), "SHA256:<hex>" for
|
||||||
|
/// X.509 certs (DER hash). Changes on key rotation.
|
||||||
|
pub fingerprints: Vec<String>,
|
||||||
|
|
||||||
|
/// Optional: bearer-token authentication for this peer. A peer that
|
||||||
|
/// also authenticates via auth_token (e.g., HTTP clients that can't
|
||||||
|
/// do TLS client-auth) stores the SHA-256 hash of the token here.
|
||||||
|
/// Resolution via resolve_from_token matches this field and returns
|
||||||
|
/// the same Identity { id: peer_id, ... } as the fingerprint path.
|
||||||
|
pub auth_token_hash: Option<String>,
|
||||||
|
|
||||||
/// Authorization scopes granted to this peer. Resolved into
|
/// Authorization scopes granted to this peer. Resolved into
|
||||||
/// Identity.scopes.
|
/// Identity.scopes.
|
||||||
pub scopes: Vec<String>,
|
pub scopes: Vec<String>,
|
||||||
|
|
||||||
/// Named resource lists granted to this peer. Resolved into
|
/// Named resource lists granted to this peer. Resolved into
|
||||||
/// Identity.resources. Populated from config (not just composition, as
|
/// Identity.resources.
|
||||||
/// the pre-ADR-030 limitation in auth.md §"Resource-scoped ACLs and
|
|
||||||
/// external identities" required).
|
|
||||||
pub resources: HashMap<String, Vec<String>>,
|
pub resources: HashMap<String, Vec<String>>,
|
||||||
|
|
||||||
/// Human-readable display name for logs / UIs. Optional.
|
/// Human-readable display name for logs / UIs. Optional.
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
|
|
||||||
/// Whether this peer is authorized at all. false = the fingerprint
|
/// Whether this peer is authorized at all. false = recognized but
|
||||||
/// is recognized but the peer is disabled (token-revoked-equivalent
|
/// disabled (revoked). Resolution returns None.
|
||||||
/// for fingerprints). Resolution returns None.
|
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AuthPolicy {
|
pub struct AuthPolicy {
|
||||||
/// Replaces authorized_fingerprints: HashSet<String>. Each entry maps
|
/// Replaces authorized_fingerprints: HashSet<String>. Each entry maps
|
||||||
/// a stable logical peer_id to its current fingerprint + scopes +
|
/// a stable logical peer_id to its credential paths (fingerprints,
|
||||||
/// resources. The list is keyed by peer_id; resolution looks up by
|
/// optional auth_token_hash) + scopes + resources. The list is keyed
|
||||||
/// fingerprint.
|
/// by peer_id; resolution looks up by fingerprint OR auth_token.
|
||||||
pub peers: Vec<PeerEntry>,
|
pub peers: Vec<PeerEntry>,
|
||||||
|
|
||||||
/// API keys — unchanged by this ADR (see "API keys" below).
|
/// API keys for bearer-token auth where the token IS the identity
|
||||||
|
/// (rotation = new identity). Peers that need a stable logical id
|
||||||
|
/// across credential rotation use PeerEntry.auth_token_hash instead.
|
||||||
|
/// See "Bearer tokens" below.
|
||||||
pub api_keys: Vec<ApiKeyEntry>,
|
pub api_keys: Vec<ApiKeyEntry>,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. `Identity.id` becomes `PeerEntry.peer_id` on fingerprint resolution
|
### 2. `Identity.id` becomes `PeerEntry.peer_id` on resolution (any credential path)
|
||||||
|
|
||||||
`ConfigIdentityProvider::resolve_from_fingerprint` resolves fingerprint →
|
`ConfigIdentityProvider::resolve_from_fingerprint` resolves fingerprint →
|
||||||
matching `PeerEntry` → `Identity { id: peer_entry.peer_id, scopes:
|
matching `PeerEntry` (by any entry in `fingerprints`) → `Identity { id:
|
||||||
peer_entry.scopes, resources: peer_entry.resources }`. The `Identity.id` is
|
peer_entry.peer_id, ... }`. `ConfigIdentityProvider::resolve_from_token`
|
||||||
the stable `peer_id`, not the fingerprint.
|
resolves token → matching `PeerEntry` (by `auth_token_hash`) → the same
|
||||||
|
`Identity { id: peer_entry.peer_id, ... }`. Both paths produce the same
|
||||||
|
`Identity` — the `peer_id` is the stable logical id regardless of how the
|
||||||
|
peer authenticated.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
impl AuthPolicy {
|
impl AuthPolicy {
|
||||||
pub fn resolve_identity_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
|
pub fn resolve_identity_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
|
||||||
self.peers.iter()
|
self.peers.iter()
|
||||||
.find(|p| p.enabled && p.fingerprint == fingerprint)
|
.find(|p| p.enabled && p.fingerprints.iter().any(|f| f == fingerprint))
|
||||||
.map(|p| Identity {
|
.map(|p| Identity {
|
||||||
id: p.peer_id.clone(),
|
id: p.peer_id.clone(),
|
||||||
scopes: p.scopes.clone(),
|
scopes: p.scopes.clone(),
|
||||||
resources: p.resources.clone(),
|
resources: p.resources.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resolve_identity_from_token(&self, token: &str) -> Option<Identity> {
|
||||||
|
let token_hash = sha256(token);
|
||||||
|
self.peers.iter()
|
||||||
|
.find(|p| p.enabled && p.auth_token_hash.as_deref() == Some(&token_hash))
|
||||||
|
.map(|p| Identity {
|
||||||
|
id: p.peer_id.clone(),
|
||||||
|
scopes: p.scopes.clone(),
|
||||||
|
resources: p.resources.clone(),
|
||||||
|
})
|
||||||
|
.or_else(|| self.resolve_api_key(token)) // fall through to ApiKeyEntry
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
This removes the pre-ADR-030 limitation in `auth.md`
|
If the token doesn't match any `PeerEntry.auth_token_hash`, resolution falls
|
||||||
§"Resource-scoped ACLs and external identities" — fingerprint-resolved
|
through to `resolve_api_key` (the `ApiKeyEntry` path, where `Identity.id =
|
||||||
identities now carry `resources` from the `PeerEntry`, not just from the
|
prefix`). This preserves the existing API-key path for bearer tokens that
|
||||||
composition path. The composition path (`CompositionAuthority::as_identity`,
|
ARE the identity, while adding the `PeerEntry` token path for tokens that
|
||||||
ADR-015/022) still produces its own `Identity` for internal calls; the
|
are one credential path among several for a stable logical peer.
|
||||||
external-auth path now also carries resources when configured.
|
|
||||||
|
|
||||||
### 3. Key rotation is a single `PeerEntry.fingerprint` update
|
This removes the pre-ADR-030 limitation in `auth.md`
|
||||||
|
§"Resource-scoped ACLs and external identities" — resolved identities now
|
||||||
|
carry `resources` from the `PeerEntry`, not just from the composition path.
|
||||||
|
|
||||||
|
### 3. Key rotation is a `PeerEntry` field update (no `peer_id` change)
|
||||||
|
|
||||||
Rotating a peer's TLS key:
|
Rotating a peer's TLS key:
|
||||||
- The vault derives the new key locally (ADR-020/021).
|
- The vault derives the new key locally (ADR-020/021).
|
||||||
- The remote side's config updates the `PeerEntry.fingerprint` field for
|
- The remote side's config updates the `PeerEntry.fingerprints` entry for
|
||||||
that `peer_id`. The `peer_id`, `scopes`, `resources`, ACL entries, and
|
that `peer_id`. The `peer_id`, `scopes`, `resources`, ACL entries, and
|
||||||
any `PeerRef::Specific(peer_id)` references stay stable.
|
any `PeerRef::Specific(peer_id)` references stay stable.
|
||||||
- A config reload (`ConfigReloadHandle::reload`) makes the change live.
|
- A config reload (`ConfigReloadHandle::reload`) makes the change live.
|
||||||
|
|
||||||
|
Rotating a peer's auth token:
|
||||||
|
- Update `PeerEntry.auth_token_hash` for that `peer_id`. The `peer_id`
|
||||||
|
and everything that references it stays stable.
|
||||||
|
|
||||||
No ACL update, no routing reference invalidation, no peer "disappears."
|
No ACL update, no routing reference invalidation, no peer "disappears."
|
||||||
The vault's local rotation + a remote-side config edit is the full key
|
The vault's local rotation + a remote-side config edit is the full key
|
||||||
rotation story across nodes.
|
rotation story across nodes, for any credential path.
|
||||||
|
|
||||||
### 4. `PeerId` source changes from UUID to `Identity.id` from `PeerEntry`
|
### 4. `PeerId` source changes from UUID to `Identity.id` from `PeerEntry`
|
||||||
|
|
||||||
@@ -192,27 +225,77 @@ caller-id-is-the-connection case, e.g., anonymous dial-in).
|
|||||||
The UUID fallback is removed. A connection with no resolved identity has no
|
The UUID fallback is removed. A connection with no resolved identity has no
|
||||||
`PeerId`, not a random one.
|
`PeerId`, not a random one.
|
||||||
|
|
||||||
## API keys
|
### 6. Fingerprint format normalization: `ed25519:` for raw keys
|
||||||
|
|
||||||
API keys (`ApiKeyEntry`) are **not** given the `PeerEntry` treatment. The
|
Ed25519 raw keys (RFC 7250) produce different fingerprint formats depending
|
||||||
two identity sources have different semantics:
|
on the transport:
|
||||||
|
|
||||||
| Axis | Fingerprint (PeerEntry) | API key (ApiKeyEntry) |
|
- **iroh** (direct or relay): `ed25519:<hex of 32-byte public key>` —
|
||||||
|------|-------------------------|------------------------|
|
extracted from `connection.remote_node_id()`, which returns the NodeId
|
||||||
| Identity source | TLS handshake / SSH key | Bearer token in protocol frame |
|
(the raw Ed25519 public key). Already implemented.
|
||||||
| Key rotation | Same logical node, new material | New identity (revocation = new key) |
|
- **quinn RawKey**: currently `SHA256:<hex of cert DER>` — because
|
||||||
| `Identity.id` | `peer_id` (stable across rotation) | `prefix` (changes with the key) |
|
`fingerprint_from_cert_der` hashes the SPKI DER bytes. The DER encoding
|
||||||
| Resource binding | `PeerEntry.resources` (per-peer) | Empty (Option B, auth.md) — resources are composition-only |
|
of the SPKI is not the raw 32-byte public key; it's an ASN.1 wrapper.
|
||||||
|
So the same Ed25519 key produces `ed25519:abc...` on iroh and
|
||||||
|
`SHA256:def...` on quinn — two different fingerprints for the same key.
|
||||||
|
|
||||||
An API key's prefix IS the identity — rotating the key means a new prefix
|
This is normalized: the quinn path extracts the Ed25519 public key from the
|
||||||
and a new identity, by design (revocation is the rotation mechanism for
|
cert DER (the `RawKeyCertResolver` already has the raw key bytes via
|
||||||
API keys). Decoupling the API key identity from the prefix would be solving
|
`Ed25519SecretKey::public()`) and formats it as `ed25519:<hex>`, matching
|
||||||
a different problem (persistent logical identity across key rotation) that
|
iroh. A peer that connects via quinn direct and via iroh has the same
|
||||||
API keys don't have: they're bearer tokens, not node identities.
|
fingerprint in `PeerEntry.fingerprints` — one entry, both transports.
|
||||||
|
|
||||||
`ApiKeyEntry` stays as-is. The asymmetry is documented here and in
|
The normalization is in `extract_quinn_client_fingerprint`: when the
|
||||||
`auth.md` so the difference between the two auth paths is explicit, not an
|
presented cert is an RFC 7250 raw public key (SPKI with Ed25519 algorithm
|
||||||
oversight.
|
identifier), extract the raw 32-byte public key and format as
|
||||||
|
`ed25519:<hex>`. When the cert is X.509, keep the `SHA256:<hex of DER>`
|
||||||
|
format (X.509 certs don't have a "raw public key" form — the DER hash is
|
||||||
|
the fingerprint).
|
||||||
|
|
||||||
|
This also simplifies the coming WebTransport relay work: a WebTransport
|
||||||
|
relay acts as a proxy, and the proxied connection's Ed25519 identity
|
||||||
|
should be the same `ed25519:<hex>` whether the client connected directly
|
||||||
|
or through the relay. Normalizing on the iroh pattern means the relay
|
||||||
|
doesn't need a separate fingerprint format.
|
||||||
|
|
||||||
|
## Bearer tokens
|
||||||
|
|
||||||
|
There are three credential types in the alknet auth model:
|
||||||
|
|
||||||
|
1. **Ed25519 raw key** (RFC 7250) — the most common. Same key type as SSH
|
||||||
|
keys, native to iroh's `NodeId`. Fingerprint format: `ed25519:<hex>`.
|
||||||
|
Used for direct quinn, iroh direct, and iroh relay connections. The
|
||||||
|
fingerprint IS the trust anchor (no CA needed).
|
||||||
|
|
||||||
|
2. **X.509 cert** — for domain-facing endpoints (`api.alk.dev`, relays,
|
||||||
|
ACME/Let's Encrypt). Fingerprint format: `SHA256:<hex of DER>`. Requires
|
||||||
|
CA verification on the client side. The outgoing-only case (a client
|
||||||
|
connects to a public X.509 endpoint) is tracked as OQ-37.
|
||||||
|
|
||||||
|
3. **Bearer token** (auth_token) — for HTTP clients that can't do TLS
|
||||||
|
client-auth (browsers, curl), or as a secondary credential path. Carried
|
||||||
|
in the call-protocol `auth_token` payload field.
|
||||||
|
|
||||||
|
A `PeerEntry` can have any combination of these: `fingerprints: Vec<String>`
|
||||||
|
for one or more TLS keys (Ed25519 and/or X.509), `auth_token_hash:
|
||||||
|
Option<String>` for an optional bearer-token path. All resolve to the same
|
||||||
|
`peer_id`. A peer that authenticates via Ed25519 today and via auth_token
|
||||||
|
tomorrow gets the same `PeerId` — the logical identity is stable across
|
||||||
|
credential paths.
|
||||||
|
|
||||||
|
`ApiKeyEntry` stays as a separate path for bearer tokens where the token IS
|
||||||
|
the identity (rotation = new identity, no stable logical id needed). When a
|
||||||
|
bearer token is one credential path among several for a stable peer, it
|
||||||
|
goes in `PeerEntry.auth_token_hash`. The distinction is not "peer bearer vs
|
||||||
|
auth bearer" — it's whether the token needs a stable logical id across
|
||||||
|
rotation (`PeerEntry`) or not (`ApiKeyEntry`).
|
||||||
|
|
||||||
|
| Credential type | `PeerEntry` field | `Identity.id` | Rotation |
|
||||||
|
|-----------------|-------------------|---------------|----------|
|
||||||
|
| Ed25519 raw key | `fingerprints[i]` (`ed25519:...`) | `peer_id` (stable) | Update `fingerprints` entry |
|
||||||
|
| X.509 cert | `fingerprints[i]` (`SHA256:...`) | `peer_id` (stable) | Update `fingerprints` entry |
|
||||||
|
| Bearer token (peer) | `auth_token_hash` | `peer_id` (stable) | Update `auth_token_hash` |
|
||||||
|
| Bearer token (identity) | `ApiKeyEntry` (separate) | `prefix` (changes with key) | New `ApiKeyEntry` |
|
||||||
|
|
||||||
## What this does NOT change
|
## What this does NOT change
|
||||||
|
|
||||||
@@ -237,9 +320,8 @@ oversight.
|
|||||||
|
|
||||||
**Positive:**
|
**Positive:**
|
||||||
- Key rotation no longer breaks ACL entries or routing references on the
|
- Key rotation no longer breaks ACL entries or routing references on the
|
||||||
remote side. The vault's local rotation story (ADR-021) is now the
|
remote side — for any credential path (TLS key or auth token). The
|
||||||
complete story — `rotate` locally, edit the peer entry's fingerprint
|
vault's local rotation story (ADR-021) is now the complete story.
|
||||||
remotely, reload.
|
|
||||||
- `PeerRef::Specific` survives reconnects. An in-flight routing reference
|
- `PeerRef::Specific` survives reconnects. An in-flight routing reference
|
||||||
to "worker-a" keeps resolving after worker-a's TLS key rotates and after
|
to "worker-a" keeps resolving after worker-a's TLS key rotates and after
|
||||||
worker-a reconnects.
|
worker-a reconnects.
|
||||||
@@ -250,33 +332,36 @@ oversight.
|
|||||||
future `alknet-peer-store-sqlite` adapter that persists `PeerEntry`
|
future `alknet-peer-store-sqlite` adapter that persists `PeerEntry`
|
||||||
records is additive, implementing the same `IdentityProvider` trait
|
records is additive, implementing the same `IdentityProvider` trait
|
||||||
against a `peers` table. See ADR-033.
|
against a `peers` table. See ADR-033.
|
||||||
- Fingerprint-resolved identities now carry `resources` (the pre-ADR-030
|
- Resolved identities now carry `resources` (the pre-ADR-030 limitation is
|
||||||
limitation is lifted) — `AccessControl::check` against `resource_type`/
|
lifted) — `AccessControl::check` against `resource_type`/
|
||||||
`resource_action` works for external fingerprint-authenticated callers
|
`resource_action` works for external authenticated callers when
|
||||||
when configured.
|
configured, regardless of credential path.
|
||||||
|
- A peer can authenticate via Ed25519 today and via auth_token tomorrow,
|
||||||
|
getting the same `PeerId` — the logical identity is stable across
|
||||||
|
credential paths.
|
||||||
|
- Fingerprint normalization (`ed25519:<hex>` for raw keys across quinn and
|
||||||
|
iroh) means the same key has the same fingerprint regardless of transport.
|
||||||
|
This also simplifies the coming WebTransport relay work.
|
||||||
|
|
||||||
**Negative:**
|
**Negative:**
|
||||||
- `AuthPolicy.authorized_fingerprints: HashSet<String>` is replaced with
|
- `AuthPolicy.authorized_fingerprints: HashSet<String>` is replaced with
|
||||||
`AuthPolicy.peers: Vec<PeerEntry>`. This is a breaking config change —
|
`AuthPolicy.peers: Vec<PeerEntry>`. This is a breaking config change —
|
||||||
existing config files with `authorized_fingerprints` migrate to `peers`
|
existing config files with `authorized_fingerprints` migrate to `peers`
|
||||||
entries. The migration is mechanical (each fingerprint becomes a
|
entries. The migration is mechanical (each fingerprint becomes a
|
||||||
`PeerEntry { peer_id: <chosen name>, fingerprint: <old value>, scopes:
|
`PeerEntry { peer_id: <chosen name>, fingerprints: vec![<old value>], ... }`),
|
||||||
["relay:connect"], ... }`), and operators must choose a `peer_id` per
|
and operators must choose a `peer_id` per peer, but it is a config break.
|
||||||
peer, but it is a config break.
|
- `Identity.id` for resolved identities changes from the fingerprint to
|
||||||
- `Identity.id` for fingerprint-resolved identities changes from the
|
the `peer_id`. Code that logs or compares `Identity.id` and assumed it
|
||||||
fingerprint to the `peer_id`. Code that logs or compares `Identity.id`
|
was the fingerprint string will see the `peer_id` instead. This is the
|
||||||
on the fingerprint path and assumed it was the fingerprint string will
|
correct behavior (logs should show the logical name, not the rotating
|
||||||
see the `peer_id` instead. This is the correct behavior (logs should
|
crypto material), but it's a behavior change in log output.
|
||||||
show the logical name, not the rotating crypto material), but it's a
|
- The quinn fingerprint extraction changes from `SHA256:<hex of DER>` to
|
||||||
behavior change in log output.
|
`ed25519:<hex of raw key>` for raw-key certs. Existing configs with
|
||||||
- The pre-ADR-030 `auth.md` "Resource-scoped ACLs and external identities"
|
`SHA256:` fingerprints for Ed25519 keys migrate to `ed25519:` format.
|
||||||
limitation note is removed — fingerprint-resolved identities now populate
|
X.509 fingerprints stay as `SHA256:<hex of DER>`.
|
||||||
`resources`. Code that relied on fingerprint identities always having
|
|
||||||
empty `resources` (an unintended invariant) will see populated resources
|
|
||||||
when configured.
|
|
||||||
- ADR-029 Assumption 1 is superseded on the `PeerId` source dimension:
|
- ADR-029 Assumption 1 is superseded on the `PeerId` source dimension:
|
||||||
the one-way door (`PeerId` is logical, not crypto) is preserved, but the
|
the one-way door (`PeerId` is logical, not crypto) is preserved, but the
|
||||||
v1 UUID source is replaced by `Identity.id` from `PeerEntry`. The
|
UUID source is replaced by `Identity.id` from `PeerEntry`. The
|
||||||
Assumption's framing of "no-storage workaround" is no longer accurate —
|
Assumption's framing of "no-storage workaround" is no longer accurate —
|
||||||
the storage boundary is now explicitly `config + in-memory adapter`
|
the storage boundary is now explicitly `config + in-memory adapter`
|
||||||
(this ADR + ADR-033), with the SQLite adapter additive.
|
(this ADR + ADR-033), with the SQLite adapter additive.
|
||||||
@@ -295,9 +380,13 @@ oversight.
|
|||||||
Config validation enforces uniqueness; duplicate `peer_id` values in
|
Config validation enforces uniqueness; duplicate `peer_id` values in
|
||||||
`AuthPolicy.peers` are a config error.
|
`AuthPolicy.peers` are a config error.
|
||||||
|
|
||||||
3. **API keys stay as-is.** The `ApiKeyEntry` model is correct for bearer-
|
3. **Bearer tokens have two paths.** `PeerEntry.auth_token_hash` is for
|
||||||
token identity where rotation = new identity. This ADR does not add a
|
tokens that are one credential path among several for a stable logical
|
||||||
`PeerEntry`-equivalent for API keys. See "API keys" above.
|
peer (the token rotates, the `peer_id` stays). `ApiKeyEntry` is for
|
||||||
|
tokens that ARE the identity (rotation = new identity, no stable
|
||||||
|
logical id needed). See "Bearer tokens" above. The distinction is not
|
||||||
|
"peer bearer vs auth bearer" — it's whether the token needs a stable
|
||||||
|
logical id across rotation.
|
||||||
|
|
||||||
4. **The `peers` list resolution is O(peers) per fingerprint lookup.** The
|
4. **The `peers` list resolution is O(peers) per fingerprint lookup.** The
|
||||||
expected peer count per node is small (10s–100s); a linear scan with a
|
expected peer count per node is small (10s–100s); a linear scan with a
|
||||||
@@ -333,8 +422,13 @@ oversight.
|
|||||||
- OQ-34: Persistent Peer Registry (resolved by this ADR + ADR-033 — the
|
- OQ-34: Persistent Peer Registry (resolved by this ADR + ADR-033 — the
|
||||||
storage boundary is `config + in-memory adapter` now, SQLite adapter
|
storage boundary is `config + in-memory adapter` now, SQLite adapter
|
||||||
additive)
|
additive)
|
||||||
- OQ-35: API Key Identity vs Peer Identity (recorded by this ADR — the
|
- ~~OQ-35: API Key Identity vs Peer Identity~~ (dissolved — the
|
||||||
asymmetry is deliberate, see "API keys" above)
|
"asymmetry" framing was wrong; `PeerEntry` supports multiple credential
|
||||||
|
paths, and `ApiKeyEntry` is for tokens that ARE the identity)
|
||||||
|
- OQ-29: CallClient TLS Client-Auth (resolved by this ADR's §6 fingerprint
|
||||||
|
normalization + the client-auth wiring decision recorded in OQ-29)
|
||||||
|
- OQ-37: X.509 outgoing-only case (the three auth types and how X.509
|
||||||
|
server identity fits — see OQ-37 in open-questions.md)
|
||||||
- `docs/research/alknet-storage-strategy/findings.md` §4 (the `PeerEntry`
|
- `docs/research/alknet-storage-strategy/findings.md` §4 (the `PeerEntry`
|
||||||
model and resolution path)
|
model and resolution path)
|
||||||
- `docs/architecture/crates/core/auth.md` (the spec amended by this ADR)
|
- `docs/architecture/crates/core/auth.md` (the spec amended by this ADR)
|
||||||
|
|||||||
@@ -414,73 +414,52 @@ is a feature extension, not an unmade architecture decision.
|
|||||||
### OQ-29: CallClient TLS Client-Auth and Remote-Identity Verification
|
### OQ-29: CallClient TLS Client-Auth and Remote-Identity Verification
|
||||||
|
|
||||||
- **Origin**: [client-and-adapters.md](crates/call/client-and-adapters.md), ADR-017 §7
|
- **Origin**: [client-and-adapters.md](crates/call/client-and-adapters.md), ADR-017 §7
|
||||||
- **Status**: **open — load-bearing on ADR-030** (not "additive" as previously framed)
|
- **Status**: **resolved** (2026-06-27 by ADR-030 §6 + this decision)
|
||||||
- **Door type**: One-way (identity model interaction), two-way (mechanism)
|
- **Door type**: One-way (identity model interaction), two-way (mechanism)
|
||||||
- **Priority**: **high** (was medium; promoted — this is the activation path
|
- **Priority**: ~~high~~ → resolved
|
||||||
for ADR-030's `PeerEntry` fingerprint → `peer_id` resolution)
|
- **Resolution**: **Three things are decided:**
|
||||||
- **Resolution**: **Previously framed as "additive — two-way-door
|
|
||||||
remainder." That framing is incorrect.** ADR-030 makes `PeerId =
|
|
||||||
Identity.id = PeerEntry.peer_id` on the fingerprint path. But the
|
|
||||||
fingerprint path requires the client to present a TLS client cert, and
|
|
||||||
the current `CallClient::connect()` uses `with_no_client_auth()` — no
|
|
||||||
client cert is presented, no fingerprint is extracted by the server's
|
|
||||||
`AcceptAnyCertVerifier`, and `IdentityProvider::resolve_from_fingerprint`
|
|
||||||
returns `None`. The peer gets no `PeerId` from the fingerprint path.
|
|
||||||
|
|
||||||
The `auth_token` path (`resolve_from_token`) still works, but it
|
1. **Wire quinn client-auth.** The client presents its Ed25519 key as an
|
||||||
resolves to `Identity.id = ApiKeyEntry.prefix` (the API-key identity
|
RFC 7250 raw public key client cert (the client-side equivalent of
|
||||||
path), **not** to `PeerEntry.peer_id`. So with TLS client-auth unwired,
|
the server's `RawKeyCertResolver`). The server's
|
||||||
a calling peer's `PeerId` is either `None` (no client cert) or an
|
`AcceptAnyCertVerifier` already requests client certs and extracts
|
||||||
API-key prefix (if an `auth_token` is used) — neither is the stable
|
the fingerprint — the gap was entirely on the client side
|
||||||
`PeerEntry.peer_id` that ADR-030 commits. The PeerEntry path is dormant
|
(`with_no_client_auth()` → present the key). This activates the
|
||||||
until client-auth is wired.
|
`PeerEntry` fingerprint → `peer_id` resolution path on quinn
|
||||||
|
connections.
|
||||||
|
|
||||||
This is not a "two-way-door remainder" — it's the activation path for
|
2. **Key-type-aware server cert verification.** The client's
|
||||||
ADR-030's primary use case (stable `peer_id` across key rotation for
|
`ServerCertVerifier` depends on the remote's identity type:
|
||||||
peer-keyed overlays). The decision to make is:
|
- **Ed25519 raw key** (the common case): accept the cert, extract the
|
||||||
|
fingerprint, match against `PeerEntry.fingerprints`. The fingerprint
|
||||||
|
IS the trust anchor — no CA needed. (Same model as iroh.)
|
||||||
|
- **X.509** (domain-facing endpoints, ACME): verify against a CA
|
||||||
|
(rustls's `WebPkiServerVerifier` with the platform root store or a
|
||||||
|
configured CA). `AcceptAnyServerCertVerifier` is a security hole for
|
||||||
|
X.509 — it's only safe for raw keys.
|
||||||
|
- The verifier choice is driven by `CallCredentials.remote_identity`,
|
||||||
|
which carries the expected key type.
|
||||||
|
|
||||||
- **(a)** Wire TLS client-auth as part of the ADR-029 migration, so the
|
3. **Fingerprint normalization** (ADR-030 §6): the quinn path extracts
|
||||||
fingerprint → `PeerEntry` → `peer_id` path is live from day one. The
|
the raw Ed25519 public key from the SPKI cert and formats it as
|
||||||
server's `AcceptAnyCertVerifier` already requests (but doesn't verify)
|
`ed25519:<hex>`, matching iroh. The same key has the same fingerprint
|
||||||
client certs; the client's `with_no_client_auth()` is the gap. Wiring
|
regardless of transport. X.509 fingerprints stay as `SHA256:<hex of
|
||||||
the local node's `RawKey`/`X509` identity as a rustls client-auth cert
|
DER>`.
|
||||||
is the missing piece. Remote-identity verification (plugging
|
|
||||||
`credentials.remote_identity` into a real `ServerCertVerifier`) is
|
|
||||||
genuinely additive — the server-side fingerprint extraction is what
|
|
||||||
matters for `PeerId`, not the client-side verification of the server.
|
|
||||||
|
|
||||||
- **(b)** Ship the ADR-029 migration with `auth_token`-only peer identity
|
**The iroh path already works** — iroh uses RFC 7250 raw keys, both
|
||||||
and treat TLS client-auth as a follow-up. This means `PeerCompositeEnv`
|
sides automatically exchange Ed25519 public keys during the TLS
|
||||||
keys on `Identity.id = ApiKeyEntry.prefix` (the token prefix) until
|
handshake, and `extract_iroh_client_fingerprint` already gets the
|
||||||
client-auth is wired, then switches to `PeerEntry.peer_id` when it is.
|
`NodeId`. No client-auth wiring needed for iroh (direct or relay). The
|
||||||
The switch is a behavior change for any deployment that built on the
|
gap was quinn-only.
|
||||||
token-prefix identity — the `PeerId` changes from the prefix to the
|
|
||||||
`peer_id`. This is the "compounds into a mess" path.
|
|
||||||
|
|
||||||
- **(c)** Extend `PeerEntry` to also cover `auth_token`-based peer
|
**What's genuinely additive** (not blocking the ADR-029 migration):
|
||||||
identity — a peer entry keyed by token prefix (or a `PeerEntry.token`
|
remote-identity verification (the client verifying the server's
|
||||||
field) instead of (or alongside) fingerprint. This unifies the two
|
fingerprint against an expected value) is additive — the server-side
|
||||||
identity paths under `PeerEntry`, so the `PeerId` is stable regardless
|
fingerprint extraction is what matters for `PeerId`, not the client-side
|
||||||
of which credential path the peer used. This is a design change to
|
verification. The verifier for raw keys can start as "accept any, extract
|
||||||
ADR-030, not just an implementation choice.
|
fingerprint" and add fingerprint-pinning later.
|
||||||
|
|
||||||
**The X.509 / raw-key wrinkle:** the vast majority of end users will use
|
See ADR-030 §6 for the fingerprint normalization details.
|
||||||
Ed25519 raw keys (RFC 7250) — the same key type as SSH keys, native to
|
|
||||||
iroh's `NodeId` model. The fingerprint format for raw keys is
|
|
||||||
`ed25519:<hex>`. For X.509 (public-facing endpoints like
|
|
||||||
`api.alk.dev`, relays), the fingerprint is `SHA256:<hex of DER>` — a
|
|
||||||
different format, a different key type, but the same `PeerEntry.fingerprint`
|
|
||||||
field. The `IdentityProvider::resolve_from_fingerprint` path is
|
|
||||||
format-agnostic (it's a string match against `PeerEntry.fingerprint`),
|
|
||||||
so both key types work once client-auth is wired. The wrinkle is on the
|
|
||||||
client side: presenting an Ed25519 raw key as a TLS client cert uses a
|
|
||||||
different rustls path than presenting an X.509 cert. Both are supported
|
|
||||||
by rustls; the `CallCredentials.tls_identity` field already carries the
|
|
||||||
`TlsIdentity` enum (RawKey / X509). The wiring is per-variant.
|
|
||||||
|
|
||||||
**Not decided yet.** This OQ is promoted to high priority and requires a
|
|
||||||
decision before the ADR-029 migration lands. The previous "additive,
|
|
||||||
two-way-door remainder" framing is struck.
|
|
||||||
- **Cross-references**: ADR-014, ADR-017, ADR-027, ADR-029, ADR-030,
|
- **Cross-references**: ADR-014, ADR-017, ADR-027, ADR-029, ADR-030,
|
||||||
[client-and-adapters.md](crates/call/client-and-adapters.md),
|
[client-and-adapters.md](crates/call/client-and-adapters.md),
|
||||||
[endpoint.md](crates/core/endpoint.md), [auth.md](crates/core/auth.md)
|
[endpoint.md](crates/core/endpoint.md), [auth.md](crates/core/auth.md)
|
||||||
@@ -615,28 +594,30 @@ is a feature extension, not an unmade architecture decision.
|
|||||||
|
|
||||||
## Theme: Storage and Adapters
|
## Theme: Storage and Adapters
|
||||||
|
|
||||||
### OQ-35: API Key Identity vs Peer Identity
|
### OQ-35: ~~API Key Identity vs Peer Identity~~ (Dissolved)
|
||||||
|
|
||||||
- **Origin**: ADR-030 §"API keys" (the asymmetry between the two auth paths)
|
- **Origin**: ADR-030 §"API keys" (the asymmetry between the two auth paths)
|
||||||
- **Status**: resolved (recorded by ADR-030, not a blocker)
|
- **Status**: **dissolved** (2026-06-27 — the framing was wrong)
|
||||||
- **Door type**: One-way (the asymmetry is deliberate, not an oversight)
|
- **Door type**: ~~One-way~~
|
||||||
- **Priority**: medium
|
- **Priority**: ~~medium~~
|
||||||
- **Resolution**: The fingerprint auth path gets the `PeerEntry`
|
- **Resolution**: **Dissolved.** The original framing ("the fingerprint
|
||||||
id-decoupling treatment (`Identity.id = peer_id`, stable across key
|
path gets `PeerEntry` id-decoupling, the API-key path doesn't — the
|
||||||
rotation); the API-key auth path does not (`Identity.id = prefix`,
|
asymmetry is deliberate") was based on a false distinction between "peer
|
||||||
changes with the key). This is deliberate:
|
bearer" and "auth bearer" tokens. The correct framing is the three
|
||||||
|
credential types (Ed25519, X.509, bearer token) and whether the token
|
||||||
|
needs a stable logical id across rotation:
|
||||||
|
|
||||||
- Node identity (fingerprint path) must survive key rotation — the
|
- `PeerEntry` supports multiple credential paths: `fingerprints: Vec<String>`
|
||||||
same logical node rotates its TLS key, and every ACL entry / routing
|
(Ed25519 and/or X.509) + `auth_token_hash: Option<String>` (bearer
|
||||||
reference to it should stay stable. `PeerEntry` provides this.
|
token). All resolve to the same `peer_id`.
|
||||||
- Bearer-token identity (API-key path) IS the token — rotating the key
|
- `ApiKeyEntry` is for bearer tokens that ARE the identity (rotation =
|
||||||
means a new prefix and a new identity, by design (revocation is the
|
new identity, no stable logical id needed).
|
||||||
rotation mechanism for API keys). Decoupling the API key identity
|
|
||||||
from the prefix would solve a problem API keys don't have.
|
|
||||||
|
|
||||||
The asymmetry is documented in `auth.md` ("API keys vs peer entries")
|
A bearer token that is one credential path among several for a stable
|
||||||
and in ADR-030 §"API keys" so it's explicit, not an oversight. See
|
peer goes in `PeerEntry.auth_token_hash`. A bearer token that IS the
|
||||||
[auth.md](crates/core/auth.md) for the table comparing the two paths.
|
identity stays in `ApiKeyEntry`. The distinction is whether the token
|
||||||
|
needs a stable logical id across rotation, not "peer bearer vs auth
|
||||||
|
bearer." See ADR-030 §"Bearer tokens."
|
||||||
- **Cross-references**: ADR-030, [auth.md](crates/core/auth.md),
|
- **Cross-references**: ADR-030, [auth.md](crates/core/auth.md),
|
||||||
[config.md](crates/core/config.md)
|
[config.md](crates/core/config.md)
|
||||||
|
|
||||||
@@ -679,4 +660,62 @@ is a feature extension, not an unmade architecture decision.
|
|||||||
pattern is committed, the in-memory adapters ship with core, and the
|
pattern is committed, the in-memory adapters ship with core, and the
|
||||||
persistence adapter shapes are the open exploration.
|
persistence adapter shapes are the open exploration.
|
||||||
- **Cross-references**: ADR-030, ADR-031, ADR-033, OQ-34,
|
- **Cross-references**: ADR-030, ADR-031, ADR-033, OQ-34,
|
||||||
[auth.md](crates/core/auth.md), [config.md](crates/core/config.md)
|
[auth.md](crates/core/auth.md), [config.md](crates/core/config.md)
|
||||||
|
|
||||||
|
## Theme: TLS Identity
|
||||||
|
|
||||||
|
### OQ-37: X.509 Outgoing-Only Case (Three Auth Types)
|
||||||
|
|
||||||
|
- **Origin**: ADR-030 §"Bearer tokens" (the three credential types), the
|
||||||
|
discussion that X.509 is fundamentally different from Ed25519
|
||||||
|
- **Status**: open (lingering — the X.509 server-identity case needs design)
|
||||||
|
- **Door type**: One-way (how X.509 server identity integrates with the
|
||||||
|
peer model)
|
||||||
|
- **Priority**: medium
|
||||||
|
- **Resolution**: The three credential types are: Ed25519 raw key (the
|
||||||
|
common case, normalized to `ed25519:<hex>` across quinn/iroh), X.509
|
||||||
|
(domain-facing endpoints, ACME, `SHA256:<hex>`), and bearer token
|
||||||
|
(`PeerEntry.auth_token_hash` or `ApiKeyEntry`).
|
||||||
|
|
||||||
|
Ed25519 and bearer token are resolved (ADR-030 + OQ-29). The X.509 case
|
||||||
|
that remains open is **outgoing-only**: a client connects to a public
|
||||||
|
X.509 endpoint (e.g., `api.alk.dev`). The client must verify the server
|
||||||
|
cert against a CA (rustls's `WebPkiServerVerifier`) — the
|
||||||
|
`AcceptAnyServerCertVerifier` is a security hole for X.509. The server
|
||||||
|
may or may not require a client cert (most public X.509 endpoints
|
||||||
|
won't — browsers can't easily do TLS client-auth).
|
||||||
|
|
||||||
|
What's resolved:
|
||||||
|
- The `PeerEntry.fingerprints` field accepts X.509 fingerprints
|
||||||
|
(`SHA256:<hex of DER>`) alongside Ed25519 fingerprints.
|
||||||
|
- The client-side verifier is key-type-aware (OQ-29): raw keys use
|
||||||
|
fingerprint-matching, X.509 uses CA verification.
|
||||||
|
|
||||||
|
What's open:
|
||||||
|
- How does the outgoing X.509 case interact with `PeerEntry`? If a
|
||||||
|
client connects to `api.alk.dev` (X.509, no client-auth), the client
|
||||||
|
doesn't present a cert, so the server has no fingerprint to resolve.
|
||||||
|
The client authenticates via `auth_token` (the bearer-token path).
|
||||||
|
The server's `PeerEntry` for this client uses `auth_token_hash`, not
|
||||||
|
`fingerprints`. This works — but the server's `PeerEntry` might not
|
||||||
|
have a fingerprint at all for an HTTP-only client.
|
||||||
|
- Conversely, if the server requires X.509 client-auth (mutual TLS),
|
||||||
|
the client presents its X.509 cert, the server extracts the
|
||||||
|
`SHA256:<hex>` fingerprint, and `PeerEntry.fingerprints` matches it.
|
||||||
|
This works too.
|
||||||
|
- The open question is whether there are cases where X.509 server
|
||||||
|
identity needs to be part of the `PeerEntry` model (the server's
|
||||||
|
identity, not the client's) — e.g., for the client to know "I'm
|
||||||
|
connected to `api.alk.dev`, which is peer-id `api-server`." Currently
|
||||||
|
`PeerEntry` is about the *remote* peer's credentials, as seen by the
|
||||||
|
*local* node. For an outgoing connection, the local node is the
|
||||||
|
client, and `PeerEntry` describes the server. This may need a
|
||||||
|
design pass to make sure the model is symmetric.
|
||||||
|
|
||||||
|
Not blocking the ADR-029 migration — the Ed25519 path is the primary
|
||||||
|
use case and it's resolved. The X.509 outgoing-only case is a real
|
||||||
|
question but it's downstream (the HTTP crate phase, when
|
||||||
|
`from_openapi`/`from_mcp` handlers connect to X.509 endpoints).
|
||||||
|
- **Cross-references**: ADR-027, ADR-029, ADR-030, OQ-29,
|
||||||
|
[client-and-adapters.md](crates/call/client-and-adapters.md),
|
||||||
|
[endpoint.md](crates/core/endpoint.md), [auth.md](crates/core/auth.md)
|
||||||
Reference in New Issue
Block a user