Commits the concrete adapter shape deferred by ADR-033: read-sync / write-async split with honker NOTIFY/LISTEN for no-restart cache invalidation, against SQLite, in a separate alknet-store-sqlite crate. Two constraints drive the design: (1) the hot-path read trait (IdentityProvider::resolve_from_fingerprint, CredentialStore::get) is sync — called in the accept loop, no .await — so a SQLite-backed adapter must cache in memory and serve sync reads from the cache; (2) auth changes must take effect without a restart (an early issue the project already fixed for ConfigIdentityProvider via ArcSwap config reload). honker's SQLite NOTIFY/LISTEN (single-digit-ms wake, no polling) is the cache-invalidation mechanism that makes both hold: write commits to SQLite + emits NOTIFY, the running process's LISTEN wakes, the in-memory index reloads and atomically swaps, the next read sees the new state. Same ArcSwap-reload pattern as config, generalized from 'config file is source of truth' to 'SQLite is source of truth, honker signals when it changed.' New async IdentityStore write trait (put_peer / update_peer / remove_peer) extends the sync IdentityProvider read trait for peer mutations. ConfigIdentityProvider does NOT implement it (config reload is its write path — a posture enforced by the absence of a backend, not a type-system constraint); SqliteIdentityProvider implements both. CredentialStore::put/delete refined to async (within ADR-031's one-way door — the contract was get/put/delete keyed by provider persisting EncryptedData never decrypting; sync-vs-async was unspecified). CredentialStoreError renamed to shared StoreError covering both traits. alknet-store-sqlite is one crate implementing both IdentityStore and CredentialStore with shared SQLite connection + honker LISTEN infra (splitting later is a two-way door). Schema shape committed (one row per PeerEntry with JSON columns for fingerprints/scopes/resources; one row per EncryptedData blob keyed by provider); exact DDL is an implementation-detail two-way door in the adapter crate. The keypal adapter-factory pattern is intentionally not ported to Rust (runtime column-mapping is a TS affordance; in Rust each adapter is a concrete type, cross-cutting concerns are a shared helper module). Amends ADR-031 (put/delete async refinement, StoreError rename), ADR-033 (concrete adapter shape now specified, two-crate framing collapsed to one), ADR-034 (OQ-36 now resolved), auth.md (IdentityStore section, cache-invalidation summary, OQ-36 reference), config.md (two write paths note), and the OQ-36/OQ-34 entries in open-questions.md. Review fixed 4 criticals (error-type name divergence, duplicate IdentityProvider sketch, upsert/Duplicate ambiguity, 'shape unchanged' contradiction), 7 warnings, 5 suggestions.
28 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-06-28 |
Authentication
AuthContext, Identity, IdentityProvider, AuthToken, and the resolution flow.
See ADR-004 and ADR-011 for rationale.
AuthContext
Created by the endpoint for each incoming connection. Passed to ProtocolHandler::handle() as an immutable reference.
#[derive(Clone)]
pub struct AuthContext {
/// The peer's authenticated identity, if resolved by the endpoint.
/// None means the endpoint has no identity information for this connection.
pub identity: Option<Identity>,
/// The negotiated ALPN for this connection. Always present.
pub alpn: Vec<u8>,
/// The peer's remote address, if available. Informational (NAT/proxy).
pub remote_addr: Option<SocketAddr>,
/// SHA-256 fingerprint of the TLS client certificate, if presented.
/// Set by the endpoint during TLS handshake. Handlers may use this for
/// fingerprint-based auth even when IdentityProvider returns None.
pub tls_client_fingerprint: Option<String>,
}
Construction by the endpoint
The endpoint constructs AuthContext from the QUIC connection:
alpn: Fromconnection.alpn()— always present after TLS handshake.remote_addr: Fromconnection.remote_addr()— may beNonefor iroh connections.tls_client_fingerprint: Extracted from the TLS session's client certificate, if one was presented.identity: If a TLS client fingerprint is available, the endpoint callsIdentityProvider::resolve_from_fingerprint(). If it resolves,identity = Some(resolved). If not,identity = None.
Handler-level resolution
Handlers that require authentication extract protocol-specific credentials and call IdentityProvider inside handle(). When identity is resolved, the handler stores it on the Connection for observability:
// Example: CallAdapter extracting an AuthToken from the first frame
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError> {
let identity = match &auth.identity {
Some(id) => id.clone(), // Endpoint already resolved identity
None => {
let stream = connection.accept_bi().await?;
let token = extract_auth_token(stream).await?;
self.identity_provider
.resolve_from_token(&token)
.ok_or(HandlerError::AuthRequired)?
}
};
connection.set_identity(identity); // Store for observability (OQ-11)
// ... proceed with authenticated identity
}
Handlers that don't require authentication (e.g., DNS resolver, health check) can ignore auth.identity entirely and don't call set_identity.
Two Identity Scopes
There are two distinct identity scopes that must not be conflated:
| Scope | Where it's set | Where it's stored | What it represents | Used for |
|---|---|---|---|---|
| Connection-level | Handler in handle() |
Connection (via set_identity) |
Who opened this QUIC connection | Observability, logging, audit |
| Per-request | CallAdapter per call.requested |
OperationContext.identity |
Who is making this specific call | ACL (ADR-015) |
The connection-level identity is stable — set once when the handler resolves it. The per-request identity is dynamic — resolved per call.requested, potentially different across requests on the same connection (if different auth tokens are used). The per-request identity takes precedence for ACL on OperationContext; the connection-level identity is for observability only, not for ACL.
Connection exposes set_identity via interior mutability — the handler sets it once when resolved, the endpoint and observability layers read it. The identity is write-once-read-many.
AuthContext is Clone and immutable
derive(Clone)allows handlers to cloneAuthContextfor per-stream or per-channel contexts.handle()receives&AuthContext— immutable. Handlers that resolve identity create local variables, they don't mutate the shared context. This prevents cross-contamination between streams on the same connection.
Identity
The authenticated peer identity. Carries authorization information.
#[derive(Debug, Clone, PartialEq)]
pub struct Identity {
/// 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:
- 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; thepeer_idis the identity. Decoupling them means key rotation changes the credential but not the identity, so ACL entries and routing references stay stable. - 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
CompositionAuthoritylabel (ADR-022) — e.g.,"agent-chat".
Credential Types
The alknet auth model has three credential types. A PeerEntry can use any combination — all resolve to the same peer_id:
| Credential type | PeerEntry field |
Fingerprint format | Trust model |
|---|---|---|---|
| Ed25519 raw key (RFC 7250) | fingerprints[i] |
ed25519:<hex of 32-byte pub key> |
Fingerprint IS the trust anchor (no CA) |
| X.509 cert | fingerprints[i] |
SHA256:<hex of DER> |
CA verification (WebPKI) |
| Bearer token (peer credential) | auth_token_hash |
SHA-256 hash of token | Token hash match |
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_idstays 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."
Three Remote Roles (ADR-034)
The three credential types above describe how a single PeerEntry can
be authenticated. Separately, there are three distinct remote roles
that the architecture must not conflate (see ADR-034):
| Role | Identity | alknet peer? | PeerEntry on local side? |
|---|---|---|---|
| Public X.509 endpoint | Domain + CA-issued X.509 | No (local node is a client) | No |
| Transport relay (iroh's DERP-equivalent) | iroh NodeId (Ed25519) |
No (infrastructure) | No |
| Hub / hosting node | Ed25519 raw key and/or X.509 | Yes (full peer) | Yes |
(Transport path and examples per role are in ADR-034; this table is
auth-focused — identity, peer-graph membership, and PeerEntry
presence on the local side.)
PeerEntry (and the PeerId it resolves to) is the model for peers in
the call-protocol peer graph (ADR-029) — peers that get a stable logical
identity, are addressable via PeerRef::Specific, and whose ops land in
the peer-keyed overlay. A pure-client connection to a public X.509
endpoint (e.g., api.alk.dev, a third-party API) is not in that
graph on the client side: the local node holds no PeerEntry for it,
the connection gets no PeerId, and ops discovered via
from_call/from_openapi/from_mcp are invoked through the
connection handle directly (Layer 2 overlay, ADR-024), not through
peer-keyed routing. The asymmetry is deliberate — a public domain's
operator can change hands, so there is no stable logical identity to
attach; the local node trusts the CA today and holds the connection
handle.
The hub case is an ordinary PeerEntry that happens to expose both
an Ed25519 fingerprint (P2P path) and an X.509 fingerprint
(SHA256:<hex>, WebTransport/HTTPS path) — already supported by
PeerEntry.fingerprints: Vec<String> (ADR-030). Browsers connecting to
a hub over WebTransport/HTTPS are not alknet peers on the hub's side
either — they're served by alknet-http, authenticate by bearer token,
and get no PeerId.
Client-side verifier selection (outgoing connections)
The CallClient / from_openapi / from_mcp client-side
ServerCertVerifier is selected by whether the local node has a
PeerEntry for the remote, not by key type alone:
Local has PeerEntry for remote? |
Remote cert type | Client verifier |
|---|---|---|
| No (public X.509 endpoint) | X.509 | WebPkiServerVerifier (CA verification) |
| No | Ed25519 raw key | fails closed (no CA to fall back to — raw-key remotes are always known peers) |
| Yes (hub, Ed25519 path) | Ed25519 raw key | fingerprint match (ed25519:<hex>) |
| Yes (hub, X.509 path) | X.509 | fingerprint match (SHA256:<hex>) |
This is the key-type-aware verifier from OQ-29, with the peer-model
criterion (ADR-034) made explicit. AcceptAnyServerCertVerifier is a
security hole for X.509 and is only safe for raw-key fingerprint
extraction on the server side; the client side must use CA
verification for unknown X.509 remotes and fingerprint pinning for
known peers.
AuthToken
Opaque authentication token carried in protocol frames.
#[derive(Debug, Clone)]
pub struct AuthToken {
pub raw: Vec<u8>,
}
Unchanged from the reference implementation. The handler that extracted it knows its encoding (UTF-8 string, binary token, etc.).
IdentityProvider
Trait for resolving credentials to identities. Implemented by ConfigIdentityProvider.
pub trait IdentityProvider: Send + Sync + 'static {
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity>;
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity>;
}
resolve_from_fingerprint(): Used by the endpoint (TLS client cert) and by SSH (key fingerprint).resolve_from_token(): Used by call protocol (AuthToken in first frame) and HTTP (Bearer header).
Both methods return Option<Identity> — None means the credential is not recognized.
ConfigIdentityProvider
The default implementation. Resolves identities from DynamicConfig:
pub struct ConfigIdentityProvider {
dynamic: Arc<ArcSwap<DynamicConfig>>,
}
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.peersfor the matchingPeerEntry(by any entry infingerprints). If found andenabled, returnIdentity { id: peer.peer_id, scopes: peer.scopes, resources: peer.resources }. TheIdentity.idis the stablepeer_id, not the fingerprint — key rotation changes the fingerprint but not thepeer_id, so ACL entries and routing references stay stable (ADR-030). - Token: Hash the token and look up in
DynamicConfig::auth.peersfor a matchingauth_token_hash. If found, returnIdentity { id: peer.peer_id, ... }— the samepeer_idas the fingerprint path. If noPeerEntrymatches, fall through toApiKeyEntrylookup by prefix match + SHA-256 hash. If found and not expired, returnIdentity { id: prefix, scopes: entry.scopes, resources: {} }— the token IS the identity,Identity.idis the key prefix.
See ADR-030 for the PeerEntry model, the multi-credential resolution, and the fingerprint normalization rationale.
IdentityStore (write trait, ADR-035)
IdentityProvider (defined above) is read-only and stays
read-only — it is the hot-path trait called on every incoming
connection (sync, no .await). Peer mutations (add/update/remove
a PeerEntry) go through a separate async write trait that extends
IdentityProvider:
/// Write trait — management path, async (ADR-035). ConfigIdentityProvider
/// does NOT implement this (config reload is its write path — see below).
/// SqliteIdentityProvider does: writes hit SQLite, emit honker NOTIFY,
/// and the local LISTEN refreshes the in-memory read index.
#[async_trait]
pub trait IdentityStore: IdentityProvider {
async fn put_peer(&self, peer: &PeerEntry) -> Result<(), StoreError>;
async fn update_peer(&self, peer_id: &str, peer: &PeerEntry) -> Result<(), StoreError>;
async fn remove_peer(&self, peer_id: &str) -> Result<(), StoreError>;
}
IdentityStore: IdentityProvider is a supertrait — any type that
implements IdentityProvider could implement IdentityStore, but
not implementing it is a design posture, not a type-system constraint.
ConfigIdentityProvider deliberately does not implement
IdentityStore: it holds no SQLite handle and no backend, and its
write path is config reload (ConfigReloadHandle::reload), not a
method call. This preserves the config-is-source-of-truth model.
Implementing IdentityStore for ConfigIdentityProvider "for
symmetry" would violate that model — the constraint is the absence of
a backend, not a trait bound. A deployment that wants method-call
peer management (CLI alknet peer add, an admin call-protocol
operation) wires the SQLite adapter (SqliteIdentityProvider), which
implements both IdentityProvider (sync reads from cache) and
IdentityStore (async writes to SQLite + honker NOTIFY).
Cache invalidation without restart (ADR-035)
The no-restart-on-auth-change property — already established for
ConfigIdentityProvider via ArcSwap config reload — is preserved by
the SQLite adapter via honker's SQLite NOTIFY/LISTEN:
- A write (
put_peer/update_peer/remove_peer) commits to SQLite and emitsNOTIFY 'peers_changed'. - The running alknet process's
LISTENwakes in single-digit ms (honker watchesPRAGMA data_version— no polling, no daemon). - The process reloads its in-memory index from
SELECT * FROM peersand atomically swaps it (ArcSwap, same pattern as config reload). - The next
resolve_from_fingerprintcall reads the new index. Live resolution changes, no restart.
See ADR-035
for the full adapter design (the alknet-store-sqlite crate, the
schema shape, the StoreError type, the writer's-own-process cache
coherence details, and why honker is a hard dependency of the SQLite
adapter rather than an option).
Resource-scoped ACLs
Identity.resources is populated on two paths:
| Path | Source of resources |
Use case |
|---|---|---|
PeerEntry resolution (fingerprint or auth_token) |
PeerEntry.resources (ADR-030) |
External authenticated callers with per-peer resource binding |
Composition (CompositionAuthority::as_identity, ADR-015/022) |
CompositionAuthority.resources |
Internal composition calls with declared resource binding |
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.
Fingerprint string format
tls_client_fingerprint and PeerEntry.fingerprints entries use a
prefixed-hex format. The prefix identifies the key type; the body is the
hex-encoded key material. AuthPolicy::resolve_identity_from_fingerprint
scans peers for a matching fingerprints entry — no normalization — so
the extractor and the operator config must use the same 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)> |
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
with_no_client_auth()), the fingerprint is None and identity remains
unresolved at the endpoint layer. The CallClient TLS client-auth wiring
(OQ-29, resolved) presents the client's Ed25519 key as a raw public key
client cert so the server can extract the fingerprint.
Server-side client cert request
The quinn rustls::ServerConfig uses a custom AcceptAnyCertVerifier
that requests client certs but does not require them and does not verify
them against a CA. This is the "request-but-don't-require" mode: peers
that present a cert (X.509 or RFC 7250 raw key) have their fingerprint
extracted via peer_identity(); peers that don't present a cert connect
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::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.fingerprints
fields by IdentityProvider::resolve_from_fingerprint().
Resolution Flow
Endpoint-level (before handle())
QUIC connection arrives
→ TLS handshake (ALPN negotiation)
→ Extract TLS client certificate fingerprint (if presented)
→ If fingerprint present: IdentityProvider::resolve_from_fingerprint()
→ Some(identity): auth.identity = Some(identity)
→ None: auth.identity = None
→ Construct AuthContext { identity, alpn, remote_addr, tls_client_fingerprint }
→ Look up handler by alpn
→ tokio::spawn(handler.handle(connection, &auth))
Handler-level (inside handle())
Handler receives &AuthContext
→ If auth.identity is Some: use it (endpoint already resolved)
→ If auth.identity is None and handler requires auth:
→ Extract protocol-specific credential (AuthToken, SSH key, etc.)
→ Call IdentityProvider::resolve_from_token() or resolve_from_fingerprint()
→ If resolved: use the Identity
→ If not resolved: return HandlerError::AuthRequired
→ If handler doesn't require auth: proceed without identity
IdentityProvider Injection
Handlers need access to IdentityProvider to resolve credentials inside handle(). Since ProtocolHandler::handle() doesn't receive an IdentityProvider parameter, each handler must obtain it through constructor injection:
// Example: SshAdapter holds an Arc<dyn IdentityProvider>
pub struct SshAdapter {
identity_provider: Arc<dyn IdentityProvider>,
// ... other handler-specific state
}
#[async_trait]
impl ProtocolHandler for SshAdapter {
fn alpn(&self) -> &'static [u8] { b"alknet/ssh" }
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError> {
let identity = match &auth.identity {
Some(id) => id.clone(),
None => {
// Extract SSH key fingerprint, resolve via identity_provider
let fingerprint = extract_ssh_fingerprint(&connection).await?;
self.identity_provider
.resolve_from_fingerprint(&fingerprint)
.ok_or(HandlerError::AuthRequired)?
}
};
// ...
}
}
The CLI binary constructs each handler with Arc::clone(&identity_provider) and passes it when building the HandlerRegistry. This is the assembly pattern: the CLI (the only crate that depends on all handlers) wires dependencies together.
The endpoint's AlknetEndpoint also holds Arc<dyn IdentityProvider> for endpoint-level auth resolution (TLS client certificate fingerprints), but handlers don't receive it from the endpoint — they receive it at construction time from the CLI.
| Handler | Credential source | Resolution method |
|---|---|---|
| SshAdapter | SSH public key handshake | resolve_from_fingerprint() |
| CallAdapter | AuthToken in first frame | resolve_from_token() |
| HttpAdapter | Authorization: Bearer header |
resolve_from_token() |
| DnsAdapter | AuthToken in query labels | resolve_from_token() |
| GitAdapter | Signed push certificate | resolve_from_fingerprint() |
| SftpAdapter | SSH key (shares with SshAdapter) | resolve_from_fingerprint() |
Key Differences from Reference Implementation
| Aspect | Reference | New Model |
|---|---|---|
| Auth resolution | Inside SSH handler, before handle() |
Hybrid: endpoint resolves TLS-level, handler resolves protocol-level |
| AuthContext type | None (just Arc<ArcSwap<DynamicConfig>> + IdentityProvider) |
Explicit struct with optional fields |
Identity.id |
Always a fingerprint or API key prefix | Same, but ALPN-agnostic documentation |
ConfigIdentityProvider |
Depends on russh for PublicKey types |
No russh dependency; fingerprints stored as strings |
| Credential phases | A–D phases in CredentialProvider |
Two paths: fingerprint and token. No phases. |
Design Decisions
| Decision | ADR | Summary |
|---|---|---|
| Hybrid auth model | ADR-004 | Endpoint resolves TLS-level, handler resolves protocol-level |
| AuthContext with optional Identity | ADR-011 | Explicit None, not "partially authenticated" |
| AuthContext is immutable in handle() | ADR-011 | Handlers create local variables for resolved identity |
| Two resolution paths | ADR-004 | 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 | authorized_fingerprints → peers: Vec<PeerEntry>; Identity.id = peer_id (stable), not fingerprint; key rotation changes fingerprint, not identity |
| CredentialStore repo trait | ADR-031 | Second repo trait in core (alongside IdentityProvider); InMemoryCredentialStore default adapter |
| Storage boundary and repo/adapter pattern | ADR-033 | Core defines traits + in-memory defaults; persistence adapters are separate crates |
| Three remote roles and outgoing-only X.509 | ADR-034 | Public X.509 endpoint / transport relay / hub; PeerEntry asymmetry (pure-client X.509 is not a peer); client-side verifier by PeerEntry presence |
| Concrete persistence adapter shapes | ADR-035 | Read-sync / write-async split (IdentityStore async write trait); SQLite adapter caches in memory, honker NOTIFY for no-restart cache invalidation; StoreError type |
Open Questions
- OQ-29 (resolved):
CallClientTLS 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;
PeerEntrysupports multiple credential paths (fingerprints + auth_token_hash),ApiKeyEntryis for tokens that ARE the identity. See OQ-35 in open-questions.md. - OQ-37 (resolved): X.509 outgoing-only case — three remote roles named (public X.509 endpoint, transport relay, hub);
PeerEntryasymmetry is correct (pure-client X.509 connections are not in the peer graph on the client side); client-side verifier selection byPeerEntrypresence (CA verification for unknown X.509, fingerprint pin for known peers). See ADR-034 and OQ-37 in open-questions.md. - OQ-36 (resolved): Concrete persistence adapter shapes — read-sync / write-async split (
IdentityStoreasync write trait extends the syncIdentityProviderread trait); SQLite adapter caches in memory and uses honker NOTIFY/LISTEN for no-restart cache invalidation;alknet-store-sqlitecrate implements bothIdentityStoreandCredentialStore. See ADR-035 and OQ-36 in open-questions.md.
Security Constraints
These are security-critical implementation requirements, not architectural decisions (the architecture is locked by the ADRs above). They are documented here so implementation agents don't miss them.
- Token entropy: generated
alk_tokens must have ≥128 bits of entropy. The prefix (first 8 chars) is for O(1) lookup and is not secret — it appears in logs by design. SHA-256 of the full token allows offline verification; this is safe only if the full token is high-entropy. The prefix alone must not be sufficient to authenticate. - Config reload must be authenticated: a reload that adds an authorized fingerprint or API key grants access immediately (see config.md). The reload trigger must be local-only (SIGHUP, file watch) or an admin-scoped call protocol operation. A malicious reload is equivalent to root-level privilege grant.
- Connection-level identity is for observability only:
Connection::set_identitystores the handler-resolved identity for logging/audit. Per-request identity (OperationContext.identity) takes precedence for ACL. See OQ-11. - Cryptographic nonces use OsRng: AES-GCM IVs and any other cryptographic nonces must use
OsRng(or equivalent CSPRNG), notrand::random(). IV reuse under the same key is catastrophic for GCM (authenticity breaks, two-time-pad on plaintext). The vault implementation (crates/alknet-vault/src/encryption.rs) must useOsRngfor IV generation. - Derived keys are zeroized on drop: cached derived keys (
CachedKey) must deriveZeroizeandZeroizeOnDrop. When the cache evicts an entry (LRU) or the process exits without explicitlock(), derived private keys must not linger in freed heap memory. The cache must clear on drop, not just on explicitlock(). - No
unwrap()orexpect()outside tests: poisoned lock recovery usesunwrap_or_else(|e| e.into_inner())or explicit error propagation. A panic in one vault operation must not brick the vault for all other operations.