diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index b5d2ffd..3b22465 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -36,6 +36,20 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
+ - name: Verify release tag matches Cargo version
+ shell: bash
+ run: |
+ set -euo pipefail
+ package_version="$(awk '
+ /^\[workspace.package\]$/ { in_workspace = 1; next }
+ in_workspace && /^version = / {
+ gsub(/"/, "", $3)
+ print $3
+ exit
+ }
+ ' Cargo.toml)"
+ test "${GITHUB_REF_NAME}" = "v${package_version}"
+
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
@@ -47,7 +61,7 @@ jobs:
uses: houseabsolute/actions-rust-cross@v1
with:
target: ${{ matrix.target.arch }}-unknown-linux-musl
- toolchain: 1.88.0
+ toolchain: 1.98.0
args: "--locked --release --bin pb-mapper"
strip: true
diff --git a/.github/workflows/release-ui.yml b/.github/workflows/release-ui.yml
index a108439..6cefaf3 100644
--- a/.github/workflows/release-ui.yml
+++ b/.github/workflows/release-ui.yml
@@ -80,7 +80,7 @@ jobs:
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
- toolchain: 1.88.0
+ toolchain: 1.98.0
- name: Build latest Windows FFI
run: |
make build-pb-mapper-ffi-windows
@@ -128,7 +128,7 @@ jobs:
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
- toolchain: 1.88.0
+ toolchain: 1.98.0
- name: Install dependencies
run: |
sudo apt-get update -y
@@ -236,7 +236,7 @@ jobs:
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
- toolchain: 1.88.0
+ toolchain: 1.98.0
- name: Set up Android NDK
uses: nttld/setup-ndk@v1
with:
@@ -399,7 +399,7 @@ jobs:
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
- toolchain: 1.88.0
+ toolchain: 1.98.0
- name: Install appdmg
run: |
npm install -g appdmg
@@ -480,7 +480,7 @@ jobs:
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
- toolchain: 1.88.0
+ toolchain: 1.98.0
- name: Build latest iOS FFI
run: |
make build-pb-mapper-ffi-ios
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 518b522..dabf432 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -61,7 +61,7 @@ jobs:
uses: houseabsolute/actions-rust-cross@v1
with:
target: ${{ matrix.platform.target }}
- toolchain: 1.88.0
+ toolchain: 1.98.0
args: "--locked --release --bin pb-mapper"
strip: true
@@ -77,3 +77,7 @@ jobs:
LICENSE
README.md
README.zh-CN.md
+ docs/authentication-v2.md
+ docs/authentication-v2.zh-CN.md
+ docs/user-guide.md
+ docs/user-guide.zh-CN.md
diff --git a/.github/workflows/syntax-check.yml b/.github/workflows/syntax-check.yml
index 03e5e8d..e2804bc 100644
--- a/.github/workflows/syntax-check.yml
+++ b/.github/workflows/syntax-check.yml
@@ -62,8 +62,11 @@ jobs:
# belongs to the Rust side and must be matched before ui/*.
ui/native/*) rust=true ;;
ui/*) flutter=true ;;
- src/*|tests/*|examples/*) rust=true ;;
+ crates/*|src/*|tests/*|examples/*) rust=true ;;
Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml) rust=true ;;
+ # Per-crate manifests. `case` patterns match the whole path, so
+ # the unanchored entry above only ever catches the root manifest.
+ */Cargo.toml|*/Cargo.lock) rust=true ;;
# A change to this workflow has to prove itself on both.
.github/workflows/syntax-check.yml) rust=true; flutter=true ;;
esac
@@ -88,7 +91,7 @@ jobs:
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
- toolchain: 1.88.0
+ toolchain: 1.98.0
components: clippy, rustfmt
- name: Cache Rust dependencies
diff --git a/AGENTS.md b/AGENTS.md
index 5c96352..e21de91 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,19 +1,24 @@
# Repository Guidelines
## Architecture Overview
-- One `pb-mapper` binary in `src/bin/` with four role commands:
+- One `pb-mapper` binary in `crates/pb-mapper-cli/src/bin/` with five role commands:
- `server`: central router (default port 7666)
- `register`: registers local TCP/UDP services with the router
- `connect`: connects to a registered service and exposes a local port
- `status`: queries router IDs and registered keys
-- Core crates: `src/pb_server`, `src/local/{server,client}`, `src/common` (protocol, streams, listeners), `src/utils`.
+ - `admin`: issues, lists, and revokes credentials; rotates the administrator key
+- Crates, bottom-up: `pb-mapper-core` (credentials, checksum, config, addressing)
+ → `pb-mapper-auth` (credential lifecycle and persistence) → `pb-mapper-protocol`
+ (framing and secure sessions) → `pb-mapper-server` and `pb-mapper-client`, which
+ are peers → `pb-mapper-cli`. `ui/native/pb_mapper_ffi` is the C ABI cdylib.
## Project Structure & Modules
-- `src/`: Rust backend and CLI
- - `src/bin/pb-mapper.rs`: unified CLI entry point
- - `src/pb_server`, `src/local`, `src/common`, `src/utils`
+- `crates/`: the Rust workspace; the root `Cargo.toml` is a virtual manifest
+ - `crates/pb-mapper-cli/src/bin/pb-mapper.rs`: unified CLI entry point
+ - `crates/pb-mapper-{core,auth,protocol,server,client,cli}`
+ - `crates/pb-mapper-cli/tests/`: integration tests; loads env from `tests/.env`
+ - `crates/pb-mapper-cli/examples/`: runnable examples
- `ui/`: Flutter UI; Rust bridge under `ui/native/*`
-- `tests/`: integration tests; loads env from `tests/.env`
- `docker/`, `services/`, `scripts/`: container, systemd, build/release
## Build, Test, and Development Commands
@@ -28,7 +33,9 @@
Notes: CI builds release artifacts on tags `vX.Y.Z` (see `.github/workflows/release.yml`).
## Coding Style & Naming Conventions
-- Rust 2021; toolchain pinned via `rust-toolchain.toml` (CI uses 1.88.0)
+- Edition is set once in `[workspace.package]`; the toolchain is pinned in
+ `rust-toolchain.toml`, which CI installs. Both are deliberately not repeated
+ here — a version in prose goes stale on the next upgrade.
- Format: `cargo fmt --all` (4 spaces; import grouping per `rustfmt.toml`)
- Lint: `cargo clippy --all-targets -- -D warnings`
- Naming: modules/functions `snake_case`, types/traits `PascalCase`, consts `SCREAMING_SNAKE_CASE`
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 14aa463..9d6e546 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,17 @@
All notable changes to this project will be documented in this file.
+## [0.4.0] - 2026-08-18
+- Added a sole administrator credential plus renewable, expiring, and immediately revocable `pbmt1_` temporary credentials with fixed-slot O(1) lookup and isolated per-key service namespaces.
+- Added single-flight protocol-v2 authentication with directional AES-256-GCM keys, monotonic frame counters, authenticated routing metadata, durable first-flight replay protection, and optional legacy framing during migration.
+- Added encrypted snapshot/WAL authentication state, exclusive `auth.lock`, lifecycle audit records, hierarchical timing-wheel expiry, hard closure of revoked live connections, recoverable root-key rotation, and explicit auth-state reset.
+- Extended the unified CLI with temporary-key lifecycle, service/connection inventory, auth status, protocol policy, root rotation, namespace targeting, and human/JSON/NDJSON output.
+- Replaced insecure default-key fallback with first-start random administrator-key generation, retained machine-derived keys only for explicit compatibility, and updated Flutter, installers, systemd, Docker, release metadata, and bilingual documentation.
+- Recovery keys must decrypt existing snapshot or WAL state before they are persisted. Interrupted rotation and reset recover from staged `admin.key.next` and `server-instance-id.next`.
+- First-flight salts are unique for nonce 0: admission is atomic under one lock, torn replay records fail closed, and a nonce-0 error frame is sent only after that salt is reserved.
+- Pinned UI and local tunnels to the credential and relay address captured at start, and bound tunneled-frame checksums to each hop's authenticated session key.
+- Aborted pooled registration workers and accepted connection tasks on shutdown; the relay reaps connection tasks with a `JoinSet`.
+
## [0.3.0] - 2026-08-18
- Replaced the three role-specific executables with one `pb-mapper` CLI and explicit `server`, `register`, `connect`, and `status` commands.
- Consolidated release archives into one cross-platform binary artifact per target and updated Docker, installers, systemd templates, build scripts, deployment skills, and documentation to use it.
diff --git a/CLAUDE.md b/CLAUDE.md
index 37d6151..8cb35e4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
This is a Rust-based network tunneling/proxy system called `pb-mapper` that allows exposing local services to clients over a public network. The project enables users to access their home services (like file transfer servers) from anywhere by creating secure tunnels through a public server.
-The system uses one **pb-mapper** binary (`src/bin/pb-mapper.rs`) with explicit role commands:
+The system uses one **pb-mapper** binary (`crates/pb-mapper-cli/src/bin/pb-mapper.rs`) with explicit role commands:
1. **`pb-mapper server`**: Central server that manages connections between local services and clients
- Runs on port 7666 by default
@@ -25,7 +25,11 @@ The system uses one **pb-mapper** binary (`src/bin/pb-mapper.rs`) with explicit
4. **`pb-mapper status`**: Queries remote IDs and registered service keys
-5. **UI Module** (`ui/`): Flutter graphical interface
+5. **`pb-mapper admin`**: Administrator operations against a running server —
+ issuing, listing, and revoking temporary credentials, rotating the
+ administrator key, and listing services and connections
+
+6. **UI Module** (`ui/`): Flutter graphical interface
- Replaces all CLI functionality with a user-friendly GUI
- Calls into Rust through raw `dart:ffi` against the `pb-mapper-ffi` crate
- Provides comprehensive service management interface
@@ -36,62 +40,101 @@ The system works by creating a bridge between local services and remote clients
### Project Structure
+The root `Cargo.toml` is a virtual manifest; every crate lives under `crates/`,
+except the FFI cdylib, which sits next to the Flutter code that loads it.
+
```
pb-mapper/
-├── src/ # Main Rust codebase
-│ ├── bin/ # Unified pb-mapper CLI entry point
-│ ├── pb_server/ # Central server implementation
-│ ├── local/ # Local service handlers (server/client)
-│ ├── common/ # Shared utilities and protocols
-│ └── utils/ # Helper functions
+├── crates/
+│ ├── pb-mapper-core/ # Bottom layer: checksum, config, conn_id, error,
+│ │ # addr, codec, timeout, durable_file, DataLenType
+│ ├── pb-mapper-auth/ # Credential lifecycle, persistence, timing wheel
+│ ├── pb-mapper-protocol/ # Message framing, v2 secure sessions, forwarding
+│ ├── pb-mapper-server/ # Central relay server, plus the task manager
+│ ├── pb-mapper-client/ # Both tunnel ends: `register` and `connect`
+│ └── pb-mapper-cli/ # The `pb-mapper` binary, integration tests, examples
├── ui/ # Flutter UI, talking to Rust over dart:ffi
│ ├── lib/ # Flutter application code
│ │ ├── l10n/ # ARB sources and generated AppLocalizations
│ │ └── src/ffi/ # The Dart side of the FFI boundary
│ ├── native/pb_mapper_ffi/ # C ABI crate (a workspace member)
│ └── test/ # Widget tests
-├── examples/ # Example implementations
-├── tests/ # Integration tests
├── docker/ # Docker deployment configuration
└── services/ # Systemd service files
```
+The dependency graph is a DAG, and the layering is what the crate split
+encodes:
+
+```
+pb-mapper-cli pb-mapper-ffi
+ │ │
+ └────┬─────────────────┤
+ ▼ ▼
+ pb-mapper-server pb-mapper-client (peers: no reference either way)
+ └──────┬──────────┘
+ ▼
+ pb-mapper-protocol
+ ▼
+ pb-mapper-auth
+ ▼
+ pb-mapper-core
+```
+
+Note that the binary is still named `pb-mapper`, discovered from
+`src/bin/pb-mapper.rs` inside `pb-mapper-cli`. The release workflows, both
+Dockerfiles, and the install scripts hardcode that name, and `cargo build --bin
+pb-mapper` resolves it from the workspace root regardless of the crate name.
+Likewise `pb-mapper-ffi` keeps its package name, because it determines the
+`libpb_mapper_ffi.{so,dylib,a}` / `pb_mapper_ffi.dll` filenames that the Dart
+loader, two CMakeLists, four xcconfigs, and the release-ui hash checks expect.
+
### Core Modules
-#### Rust Backend (`src/`)
-- **`src/pb_server/`**: Central server implementation
- - `server.rs`: Main server logic with connection management
- - `client.rs`: Client connection handling
- - `status.rs`: Server status reporting
- - `mod.rs`: Server manager with ManagerTask and ConnTask enums
-
-- **`src/local/server/`**: Local service registration (`register` functionality)
- - `stream.rs`: Stream handling for service registration
- - `mod.rs`: Registration logic and server-side CLI implementation
- - `error.rs`: Server-specific error handling
-
-- **`src/local/client/`**: Client connection handling (`connect` functionality)
- - `stream.rs`: Stream management for client connections
- - `status.rs`: Status checking and reporting
- - `mod.rs`: Client-side CLI implementation
- - `error.rs`: Client-specific error handling
-
-- **`src/common/`**: Shared utilities and protocols
- - `message/`: Protocol definitions (command.rs, forward.rs)
- - `config.rs`: Configuration management and environment variables
- - `stream.rs`: Stream abstractions (TcpStreamProvider, UdpStreamProvider)
- - `listener.rs`: Listener abstractions (TcpListenerProvider, UdpListenerProvider)
- - `manager.rs`: Connection management utilities
- - `buffer.rs`: Buffer management for data streaming
- - `checksum.rs`: Data integrity verification
- - `conn_id.rs`: Connection ID management
- - `error.rs`: Common error definitions
-
-- **`src/utils/`**: Helper functions
- - `addr.rs`: Address resolution with OneOrMore enum for multiple addresses
- - `codec.rs`: Encryption/decryption utilities
- - `timeout.rs`: Timeout handling mechanisms
- - `udp.rs`: UDP-specific utilities
+#### Rust Backend (`crates/`)
+- **`pb-mapper-core/`**: The bottom layer; depends on no other crate here
+ - `checksum.rs`: The process credential, and the framing checksum over `datalen`
+ - `config.rs`: Environment configuration and address resolution entry points
+ - `conn_id.rs`: Connection ID types
+ - `error.rs`: The shared error type, plus the `snafu_error_*` macros
+ - `addr.rs`: Address resolution; custom DNS servers on the async path
+ - `codec.rs`: AES-256-GCM encrypt/decrypt
+ - `timeout.rs`: `RetryBackoff`
+ - `durable_file.rs`: Atomic replace and parent-directory fsync
+ - `test_support.rs`: `PROCESS_CREDENTIAL_TEST_LOCK`, shared across crates' tests
+ - `lib.rs`: `DataLenType`, which lives here so `checksum` and `error` can name it
+
+- **`pb-mapper-auth/`**: The credential subsystem, and the largest one
+ - `lib.rs`: `AuthRuntime`, `AuthContext`, `AuthFailure`, `KeyId`
+ - `runtime.rs`: Key derivation and authentication of a presented key
+ - `actor/`: The lifecycle actor — `epoch.rs` for root rotation
+ - `persistence/`: `snapshot.rs`, `wal.rs`, `blob.rs`, `admin_key.rs`, `fs.rs`
+ - `timing_wheel.rs`: Hierarchical wheel driving credential expiry
+ - `leases.rs`, `keys.rs`, `ids.rs`, `config.rs`: Leases, key material, platform dirs
+
+- **`pb-mapper-protocol/`**: Framing and the authenticated session
+ - `lib.rs`: The checksum + length framing, and the reader/writer traits
+ - `command.rs`: Request/response types (`PbConnRequest`, `LocalServer`, `AdminRequest`, …)
+ - `secure.rs`: Protocol-v2 single-flight sessions, client and server
+ - `secure/`: `frame.rs`, `first_flight.rs`, `replay.rs`, `limiter.rs`
+ - `forward.rs`: Stream and datagram forwarding
+ - `buffer.rs`: Read buffers for the framing
+
+- **`pb-mapper-server/`**: The central relay
+ - `lib.rs`: `ManagerTask` / `ConnTask`, and the routing domain model
+ - `runtime.rs`: Serialises the global routing maps and quotas (the largest file)
+ - `connection.rs`: Per-socket authentication and dispatch
+ - `server.rs`, `client.rs`: The service-side and subscriber-side loops
+ - `admin.rs`: Administrator request handling
+ - `status.rs`, `error.rs`, `manager.rs`: Status replies, errors, the task manager
+
+- **`pb-mapper-client/`**: Both ends of a tunnel
+ - `server/`: `register` — publishes a local service (`mod.rs`, `stream.rs`, `error.rs`)
+ - `client/`: `connect` — subscribes and listens locally, plus `status.rs`
+
+- **`pb-mapper-cli/`**: The binary, integration tests, and examples
+ - `src/bin/pb-mapper.rs`: Argument parsing and the role commands
+ - `src/bin/pb-mapper/admin.rs`: The `admin` subcommand
#### Flutter UI (`ui/`)
- **`lib/src/views/`**: One file per zone the shell can show
@@ -126,12 +169,17 @@ pb-mapper/
### Key Components
-1. **Message Protocol** (`src/common/message/`):
+1. **Message Protocol** (`crates/pb-mapper-protocol/`):
- **Command Protocol** (`command.rs`): Defines request/response types:
- `PbConnStatusReq`/`PbConnStatusResp`: Status checking
- `PbConnRequest`/`PbConnResponse`: Connection management
- `PbServerRequest`: Server operation requests
- - `LocalService`: Service type definitions (TCP/UDP)
+ - `LocalServer`: Service type definitions (TCP/UDP)
+ - `AdminRequest`/`AdminResponse`: Administrator operations
+ - **Secure sessions** (`secure.rs`): Protocol-v2 first flight — the initial
+ frame carries a clear-text routing prefix plus an authenticated encrypted
+ request, adding no extra round trip, and later frames on the connection use
+ directional keys with monotonic counters
- **Forward Protocol** (`forward.rs`): Data forwarding mechanisms
- Uses JSON serialization with custom framing (checksum + length header)
- Supports encryption/decryption for secure communication via ring crate
@@ -142,15 +190,17 @@ pb-mapper/
- Implements keep-alive and timeout mechanisms
- Uses actor model for concurrent connection handling
-3. **Stream Abstractions**:
- - `StreamProvider` trait for TCP/UDP stream handling
- - `ListenerProvider` trait for TCP/UDP listener management
- - Unified interface for different transport protocols
+3. **Stream Abstractions**: `StreamProvider` and `ListenerProvider` give TCP and
+ UDP one interface. These live in the external `uni-stream` crate, not in this
+ repository.
+
+4. **Authentication** (`crates/pb-mapper-auth/`): An administrator key plus
+ derived temporary credentials, persisted through a write-ahead log and
+ snapshots, with expiry driven by a hierarchical timing wheel. See
+ `docs/authentication-v2.md`.
-4. **Configuration System**:
- - Environment variable support:
- - `PB_MAPPER_SERVER`: Remote server address
- - `PB_MAPPER_KEEP_ALIVE`: TCP keep-alive setting
+5. **Configuration System**:
+ - Environment variables (see Environment Variables below)
- Command-line argument parsing with clap
- Workspace-based dependency management
@@ -184,16 +234,20 @@ and it is what lets a widget test substitute `FakePbMapperApi`
### Current UI Implementation Status
-The UI is fully implemented with the following structure:
+Every view under `ui/lib/src/views/`:
- **Main App** (`ui/lib/main.dart`): Entry point with navigation and theme management
- **Landing Page** (`main_landing_view.dart`): Central navigation hub
-- **Server Management** (`server_management_page.dart`, `server_management_view.dart`): Complete server control
-- **Service Registration** (`service_registration_page.dart`, `service_registration_view.dart`): Service registration interface
-- **Client Connection** (`client_connection_page.dart`, `client_connection_view.dart`): Client connection management
+- **Setup Wizard** (`setup_wizard_view.dart`): First-run guided setup
+- **Service Registration** (`service_registration_view.dart`): The register workspace
+- **Registered Services** (`registered_services_view.dart`): What this process has registered
+- **Client Connection** (`client_connection_view.dart`): The connect workspace
- **Status Monitoring** (`status_monitoring_view.dart`): Real-time status dashboard
- **Configuration** (`configuration_view.dart`): Environment and settings management
-- **Logging** (`log_display_widget.dart`, `log_manager.dart`): Comprehensive log viewing
+- **Logging** (`log_view_page.dart`, `src/common/log_manager.dart` (under `ui/lib/`)): The log stream
+
+There is no separate server-management view: starting and stopping the relay is
+part of the landing page and the setup wizard.
### UI Features Implemented
@@ -265,29 +319,34 @@ The UI is fully implemented with the following structure:
- **FFI Integration**: Direct `dart:ffi` calls into the `pb-mapper-ffi` crate
- **Real-time Updates**: Live status monitoring and log streaming
- **Configuration Management**: Persistent settings and environment variable management
-- **Multi-platform**: Desktop, mobile, and web support
+- **Multi-platform**: Desktop and mobile. There is no web/wasm target — the UI
+ loads a native library over `dart:ffi`, which the web cannot do.
## Development Notes
### Project Structure & Dependencies
-- **Workspace Configuration**: Multi-crate workspace with shared dependencies in root `Cargo.toml`
+- **Workspace Configuration**: Virtual manifest at the root; versions are pinned
+ once in `[workspace.dependencies]` and crates take them with `.workspace = true`
- **Memory Optimization**: Uses mimalloc-rust for improved memory allocation performance
-- **Error Handling**: Comprehensive error handling with snafu crate across all modules
+- **Error Handling**: snafu, with each crate owning its own error type and wrapping
+ the layer below as a `source` rather than sharing one workspace-wide enum
- **Async Runtime**: Built on Tokio with full async/await support
- **Serialization**: serde and serde_json for message serialization
-- **Networking**: socket2 for low-level socket operations, trust-dns-resolver for DNS
+- **Networking**: uni-stream for the stream/listener abstractions, hickory-resolver
+ for DNS (custom resolvers on the async path only — the sync path uses `std`,
+ since hickory has no blocking resolver)
- **Cryptography**: ring crate for encryption/decryption functionality
### Code Quality & Standards
-- **Linting**: Strict clippy rules in UI native hub (deny unwrap_used, expect_used, wildcard_imports)
+- **Linting**: `unwrap_used` and `expect_used` are denied for the whole workspace
+ via `[workspace.lints]`; `clippy.toml` exempts test code, and `tests/` and
+ `examples/` targets carry a file-level allow. A production `unwrap` needs a
+ reason recorded at the site.
- **Formatting**: rustfmt.toml configuration for consistent code style
- **Toolchain**: rust-toolchain.toml for reproducible builds
-- **Testing**: Comprehensive test suite in `tests/` directory
-
-### Build Profiles
-- **wasm-dev**: Optimized for WebAssembly builds
-- **server-dev**: Development profile for server components
-- **android-dev**: Android-specific build optimizations
+- **Testing**: Unit tests live beside the code; integration tests are in
+ `crates/pb-mapper-cli/tests/`, which is the crate that depends on every layer
+ they exercise
### UI Development Guidelines
- **Framework**: Flutter 3.44.9, Material 3. CI pins the same version.
@@ -299,9 +358,23 @@ The UI is fully implemented with the following structure:
- **Responsive Design**: Adaptive layouts for different screen sizes
### Environment Variables
+
+The commonly used ones:
+
- **`PB_MAPPER_SERVER`**: Default remote server address for CLI tools
-- **`PB_MAPPER_KEEP_ALIVE`**: Global TCP keep-alive setting ("ON" to enable)
+- **`PB_MAPPER_KEEP_ALIVE`**: TCP keep-alive ("ON", "1", "true", "yes" to enable).
+ Read on every call, not cached — the UI's per-service toggle depends on that.
+- **`MSG_HEADER_KEY`**: The process credential, administrator or temporary.
+ Required; there is no insecure default.
- **`RUST_LOG`**: Tracing level configuration (supports env-filter)
+- **`PB_MAPPER_LOG_FORMAT`**: Log output format
+
+Timeouts, intervals, and pool sizes are also configurable, and there are more
+than a dozen: the authoritative list is the `pub const PB_MAPPER_*` declarations
+at the top of `crates/pb-mapper-core/src/config.rs`, each read by the accessor
+named after it. Beyond those, `PB_MAPPER_AUTH_STATE_DIR`,
+`PB_MAPPER_LEGACY_PROTOCOL`, and `PB_MAPPER_NEW_STREAMS_PER_SECOND` are read by
+name where they are used; the first two are also settable as `server` flags.
## Development Workflow
diff --git a/Cargo.lock b/Cargo.lock
index d0d1077..3e44e6f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -81,9 +81,21 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.114",
]
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+
[[package]]
name = "better_mimalloc_rs"
version = "0.1.2"
@@ -119,6 +131,12 @@ dependencies = [
"rustc_version",
]
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
[[package]]
name = "bytes"
version = "1.11.0"
@@ -189,7 +207,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.114",
]
[[package]]
@@ -204,6 +222,32 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
[[package]]
name = "cpufeatures"
version = "0.3.0"
@@ -213,6 +257,36 @@ dependencies = [
"libc",
]
+[[package]]
+name = "critical-section"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
+
[[package]]
name = "cty"
version = "0.2.2"
@@ -227,23 +301,23 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "dirs"
-version = "5.0.1"
+version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
+checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
-version = "0.4.1"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
- "windows-sys 0.48.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -254,7 +328,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.114",
]
[[package]]
@@ -263,6 +337,12 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+[[package]]
+name = "either"
+version = "1.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
+
[[package]]
name = "enum-as-inner"
version = "0.6.1"
@@ -272,7 +352,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.114",
]
[[package]]
@@ -374,7 +454,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.114",
]
[[package]]
@@ -452,12 +532,93 @@ dependencies = [
"foldhash 0.2.0",
]
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.2.0",
+]
+
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "hickory-net"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183"
+dependencies = [
+ "async-trait",
+ "cfg-if",
+ "data-encoding",
+ "futures-channel",
+ "futures-io",
+ "futures-util",
+ "hickory-proto",
+ "idna 1.1.0",
+ "ipnet",
+ "jni",
+ "rand 0.10.0",
+ "thiserror 2.0.20",
+ "tinyvec",
+ "tokio",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "hickory-proto"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643"
+dependencies = [
+ "data-encoding",
+ "idna 1.1.0",
+ "ipnet",
+ "jni",
+ "once_cell",
+ "prefix-trie",
+ "rand 0.10.0",
+ "ring",
+ "thiserror 2.0.20",
+ "tinyvec",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "hickory-resolver"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "hickory-net",
+ "hickory-proto",
+ "ipconfig",
+ "ipnet",
+ "jni",
+ "moka",
+ "ndk-context",
+ "once_cell",
+ "parking_lot",
+ "rand 0.10.0",
+ "resolv-conf",
+ "smallvec",
+ "system-configuration",
+ "thiserror 2.0.20",
+ "tokio",
+ "tracing",
+]
+
[[package]]
name = "icu_collections"
version = "2.1.1"
@@ -605,6 +766,9 @@ name = "ipnet"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
+dependencies = [
+ "serde",
+]
[[package]]
name = "is_terminal_polyfill"
@@ -618,6 +782,66 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+[[package]]
+name = "jni"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
+dependencies = [
+ "cfg-if",
+ "combine",
+ "jni-macros",
+ "jni-sys",
+ "log",
+ "simd_cesu8",
+ "thiserror 2.0.20",
+ "walkdir",
+ "windows-link",
+]
+
+[[package]]
+name = "jni-macros"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "simd_cesu8",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
[[package]]
name = "kanal"
version = "0.2.0-beta2"
@@ -719,6 +943,29 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "moka"
+version = "0.12.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9"
+dependencies = [
+ "crossbeam-channel",
+ "crossbeam-epoch",
+ "crossbeam-utils",
+ "equivalent",
+ "parking_lot",
+ "portable-atomic",
+ "smallvec",
+ "tagptr",
+ "uuid",
+]
+
+[[package]]
+name = "ndk-context"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -728,11 +975,24 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+dependencies = [
+ "critical-section",
+ "portable-atomic",
+]
[[package]]
name = "once_cell_polyfill"
@@ -770,29 +1030,69 @@ dependencies = [
]
[[package]]
-name = "pb-mapper"
-version = "0.3.0"
+name = "pb-mapper-auth"
+version = "0.4.0"
+dependencies = [
+ "parking_lot",
+ "pb-mapper-core",
+ "rand 0.10.0",
+ "ring",
+ "serde",
+ "serde_json",
+ "subtle",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "pb-mapper-cli"
+version = "0.4.0"
dependencies = [
"better_mimalloc_rs",
- "bytes",
"clap",
"dotenvy",
- "futures",
- "hashbrown 0.16.1",
- "kanal",
- "once_cell",
+ "pb-mapper-auth",
+ "pb-mapper-client",
+ "pb-mapper-core",
+ "pb-mapper-protocol",
+ "pb-mapper-server",
+ "rand 0.10.0",
+ "serde_json",
+ "tokio",
+ "tokio-util",
+ "tracing",
+ "uni-stream",
+]
+
+[[package]]
+name = "pb-mapper-client"
+version = "0.4.0"
+dependencies = [
+ "pb-mapper-core",
+ "pb-mapper-protocol",
+ "serde_json",
+ "snafu",
+ "tokio",
+ "tracing",
+ "uni-stream",
+]
+
+[[package]]
+name = "pb-mapper-core"
+version = "0.4.0"
+dependencies = [
+ "base64",
+ "clap",
+ "hickory-resolver",
+ "parking_lot",
"rand 0.10.0",
"ring",
- "serde",
"serde_json",
"snafu",
- "socket2 0.6.1",
"tokio",
- "tokio-util",
"tracing",
"tracing-subscriber",
- "trust-dns-resolver",
- "uni-stream",
]
[[package]]
@@ -802,7 +1102,12 @@ dependencies = [
"better_mimalloc_rs",
"clap",
"dirs",
- "pb-mapper",
+ "parking_lot",
+ "pb-mapper-auth",
+ "pb-mapper-client",
+ "pb-mapper-core",
+ "pb-mapper-protocol",
+ "pb-mapper-server",
"serde",
"serde_json",
"tokio",
@@ -812,6 +1117,41 @@ dependencies = [
"uni-stream",
]
+[[package]]
+name = "pb-mapper-protocol"
+version = "0.4.0"
+dependencies = [
+ "bytes",
+ "parking_lot",
+ "pb-mapper-auth",
+ "pb-mapper-core",
+ "rand 0.10.0",
+ "ring",
+ "serde",
+ "serde_json",
+ "snafu",
+ "tokio",
+ "tracing",
+ "uni-stream",
+]
+
+[[package]]
+name = "pb-mapper-server"
+version = "0.4.0"
+dependencies = [
+ "hashbrown 0.17.1",
+ "kanal",
+ "pb-mapper-auth",
+ "pb-mapper-core",
+ "pb-mapper-protocol",
+ "rand 0.10.0",
+ "snafu",
+ "tokio",
+ "tokio-util",
+ "tracing",
+ "uni-stream",
+]
+
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -830,6 +1170,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -848,6 +1194,17 @@ dependencies = [
"zerocopy",
]
+[[package]]
+name = "prefix-trie"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7"
+dependencies = [
+ "either",
+ "ipnet",
+ "num-traits",
+]
+
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -855,7 +1212,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
- "syn",
+ "syn 2.0.114",
]
[[package]]
@@ -940,13 +1297,13 @@ dependencies = [
[[package]]
name = "redox_users"
-version = "0.4.6"
+version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
+checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
- "thiserror",
+ "thiserror 2.0.20",
]
[[package]]
@@ -995,6 +1352,21 @@ dependencies = [
"semver",
]
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -1034,7 +1406,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.114",
]
[[package]]
@@ -1075,6 +1447,22 @@ dependencies = [
"libc",
]
+[[package]]
+name = "simd_cesu8"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
+dependencies = [
+ "rustc_version",
+ "simdutf8",
+]
+
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
[[package]]
name = "slab"
version = "0.4.11"
@@ -1089,23 +1477,23 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "snafu"
-version = "0.8.9"
+version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2"
+checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7"
dependencies = [
"snafu-derive",
]
[[package]]
name = "snafu-derive"
-version = "0.8.9"
+version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451"
+checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72"
dependencies = [
"heck",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.114",
]
[[package]]
@@ -1140,6 +1528,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
[[package]]
name = "syn"
version = "2.0.114"
@@ -1151,6 +1545,17 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
[[package]]
name = "synstructure"
version = "0.13.2"
@@ -1159,16 +1564,52 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "system-configuration"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
+dependencies = [
+ "bitflags",
+ "core-foundation",
+ "system-configuration-sys",
+]
+
+[[package]]
+name = "system-configuration-sys"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
]
+[[package]]
+name = "tagptr"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
+
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
- "thiserror-impl",
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl 2.0.20",
]
[[package]]
@@ -1179,7 +1620,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
]
[[package]]
@@ -1241,7 +1693,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.114",
]
[[package]]
@@ -1276,7 +1728,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.114",
]
[[package]]
@@ -1349,7 +1801,7 @@ dependencies = [
"once_cell",
"rand 0.8.5",
"smallvec",
- "thiserror",
+ "thiserror 1.0.69",
"tinyvec",
"tokio",
"tracing",
@@ -1371,7 +1823,7 @@ dependencies = [
"rand 0.8.5",
"resolv-conf",
"smallvec",
- "thiserror",
+ "thiserror 1.0.69",
"tokio",
"tracing",
"trust-dns-proto",
@@ -1450,12 +1902,33 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+[[package]]
+name = "uuid"
+version = "1.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
+dependencies = [
+ "getrandom 0.4.1",
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -1480,6 +1953,51 @@ dependencies = [
"wit-bindgen",
]
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
+dependencies = [
+ "unicode-ident",
+]
+
[[package]]
name = "wasm-encoder"
version = "0.244.0"
@@ -1520,6 +2038,15 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -1788,7 +2315,7 @@ dependencies = [
"heck",
"indexmap",
"prettyplease",
- "syn",
+ "syn 2.0.114",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
@@ -1804,7 +2331,7 @@ dependencies = [
"prettyplease",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.114",
"wit-bindgen-core",
"wit-bindgen-rust",
]
@@ -1871,7 +2398,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.114",
"synstructure",
]
@@ -1892,7 +2419,7 @@ checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.114",
]
[[package]]
@@ -1912,7 +2439,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.114",
"synstructure",
]
@@ -1946,7 +2473,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.114",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 68584db..4848d5f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,80 +1,52 @@
-[package]
-name = "pb-mapper"
-version.workspace = true
-edition.workspace = true
-authors.workspace = true
-
-# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
-
-[dependencies]
-rand.workspace = true
-socket2.workspace = true
-tokio.workspace = true
-tokio-util.workspace = true
-snafu.workspace = true
-serde.workspace = true
-serde_json.workspace = true
-tracing.workspace = true
-tracing-subscriber.workspace = true
-hashbrown.workspace = true
-clap.workspace = true
-futures.workspace = true
-better_mimalloc_rs.workspace = true
-bytes.workspace = true
-trust-dns-resolver.workspace = true
-ring.workspace = true
-once_cell.workspace = true
-uni-stream.workspace = true
-kanal.workspace = true
-
-[dev-dependencies]
-dotenvy = "0.15.7"
-
-[features]
-udp-timeout = ["uni-stream/udp-timeout"]
-
[workspace]
-members = ["ui/native/pb_mapper_ffi"]
+# Every library and the CLI live under `crates/`. The FFI cdylib stays next to
+# the Flutter code that loads it.
+members = ["crates/*", "ui/native/pb_mapper_ffi"]
exclude = ["deps/uni-stream", "deps/kanal"]
+# Spelled out: a virtual manifest does not infer the resolver from the edition,
+# and without this it silently falls back to resolver 1.
+resolver = "3"
[workspace.package]
-version = "0.3.0"
+# `version` must stay the first key here: `release.yml` and `docker-publish.yml`
+# both parse it positionally with awk to check the tag against it.
+version = "0.4.0"
authors = ["L_B__"]
-edition = "2021"
+edition = "2024"
+
+[workspace.lints.clippy]
+unwrap_used = "deny"
+expect_used = "deny"
[workspace.dependencies]
+pb-mapper-auth = { path = "crates/pb-mapper-auth" }
+pb-mapper-client = { path = "crates/pb-mapper-client" }
+pb-mapper-core = { path = "crates/pb-mapper-core" }
+pb-mapper-protocol = { path = "crates/pb-mapper-protocol" }
+pb-mapper-server = { path = "crates/pb-mapper-server" }
+
+base64 = "0.23.1"
+better_mimalloc_rs = { version = "0.1.2", features = ["config"] }
+bytes = "1.11"
+clap = { version = "4.5", features = ["derive"] }
+dirs = "6.0.0"
+dotenvy = "0.15.7"
+hashbrown = { version = "0.17.1" }
+hickory-resolver = { version = "0.26.1" }
+kanal = { git = "https://github.com/acking-you/kanal.git", branch = "dev/pb-mapper" }
+parking_lot = "0.12"
rand = "0.10"
-socket2 = "0.6"
-tokio = { version = "1", features = ["full"] }
-tokio-util = "0.7"
-snafu = "0.8.7"
+ring = "0.17.14"
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", default-features = false, features = ["alloc"] }
+snafu = "0.9.2"
+subtle = "2.6.1"
+tokio = { version = "1", features = ["full"] }
+tokio-util = "0.7"
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = [
"env-filter",
"fmt",
"json",
], default-features = true }
-hashbrown = { version = "0.16" }
-clap = { version = "4.5", features = ["derive"] }
-futures = "0.3.31"
-better_mimalloc_rs = { version = "0.1.2", features = ["config"] }
-bytes = "1.11"
-trust-dns-resolver = { version = "0.23.2" }
-ring = "0.17.14"
-once_cell = "1.20.2"
uni-stream = { git = "https://github.com/acking-you/uni-stream.git", branch = "master" }
-kanal = { git = "https://github.com/acking-you/kanal.git", branch = "dev/pb-mapper" }
-
-[profile]
-
-[profile.wasm-dev]
-inherits = "dev"
-opt-level = 1
-
-[profile.server-dev]
-inherits = "dev"
-
-[profile.android-dev]
-inherits = "dev"
diff --git a/DOCKER_README.md b/DOCKER_README.md
index 7cc7bdb..da26103 100644
--- a/DOCKER_README.md
+++ b/DOCKER_README.md
@@ -13,8 +13,8 @@ docker run -d \
--name pb-mapper \
-p 7666:7666 \
-e PB_MAPPER_PORT=7666 \
- -e USE_MACHINE_MSG_HEADER_KEY=true \
-e RUST_LOG=error \
+ -v pb-mapper-auth:/var/lib/pb-mapper/auth \
ackingliu/pb-mapper:latest-x86_64_musl
```
@@ -28,11 +28,16 @@ services:
environment:
- PB_MAPPER_PORT=7666
- USE_IPV6=false
- - USE_MACHINE_MSG_HEADER_KEY=true
+ - USE_MACHINE_MSG_HEADER_KEY=false
- RUST_LOG=error
+ volumes:
+ - pb-mapper-auth:/var/lib/pb-mapper/auth
ports:
- "7666:7666"
restart: unless-stopped
+
+volumes:
+ pb-mapper-auth:
```
Save as `docker-compose.yml` and run:
@@ -46,10 +51,12 @@ docker-compose up -d
|----------|---------|-------------|
| `PB_MAPPER_PORT` | `7666` | **Required** - Port for the pb-mapper server to listen on |
| `USE_IPV6` | `false` | Enable IPv6 support (`true`/`false`) |
-| `USE_MACHINE_MSG_HEADER_KEY` | `true` | Derive `MSG_HEADER_KEY` from hostname + MAC and persist to `/var/lib/pb-mapper-server/msg_header_key` |
+| `MSG_HEADER_KEY` | unset | Optional 32-character administrator key used only to initialize a new persistent auth volume |
+| `USE_MACHINE_MSG_HEADER_KEY` | `false` | Legacy compatibility: derive the administrator key from hostname + MAC |
+| `PB_MAPPER_AUTH_STATE_DIR` | `/var/lib/pb-mapper/auth` | Persistent encrypted authentication state |
| `RUST_LOG` | `error` | Logging level (`error`, `warn`, `info`, `debug`, `trace`) |
-⚠️ **Important**: `PB_MAPPER_PORT` must be set or the container will exit with an error.
+⚠️ **Important**: `PB_MAPPER_PORT` must be set and `/var/lib/pb-mapper/auth` must be persistent. The first start creates a random administrator key at `admin.key`; losing the volume changes the root credential and loses temporary-key state.
## 📋 Ubuntu Deployment Guide
@@ -85,11 +92,16 @@ services:
environment:
PB_MAPPER_PORT: 7666
USE_IPV6: false
- USE_MACHINE_MSG_HEADER_KEY: true
+ USE_MACHINE_MSG_HEADER_KEY: false
RUST_LOG: error
+ volumes:
+ - pb-mapper-auth:/var/lib/pb-mapper/auth
ports:
- "7666:7666"
restart: unless-stopped
+
+volumes:
+ pb-mapper-auth:
EOF
```
@@ -112,6 +124,9 @@ docker-compose ps
# View logs
docker-compose logs -f pb-mapper
+
+# Read the administrator key on the Docker host
+docker exec pb-mapper cat /var/lib/pb-mapper/auth/admin.key
```
### Step 5: Verify Installation
@@ -154,18 +169,18 @@ For other architectures, you can build the image yourself using the provided Doc
|-----|-------------|
| `latest-x86_64_musl` | Latest stable x86_64 build (recommended) |
| `latest-aarch64_musl` | Latest stable ARM64 build |
-| `v0.3.0-x86_64_musl` | Tagged-release x86_64 build |
-| `v0.3.0-aarch64_musl` | Tagged-release ARM64 build |
-| `0.3.0-x86_64_musl` | Semver x86_64 alias |
-| `0.3.0-aarch64_musl` | Semver ARM64 alias |
+| `v0.4.0-x86_64_musl` | Tagged-release x86_64 build |
+| `v0.4.0-aarch64_musl` | Tagged-release ARM64 build |
+| `0.4.0-x86_64_musl` | Semver x86_64 alias |
+| `0.4.0-aarch64_musl` | Semver ARM64 alias |
**Recommendation**: Use `latest-x86_64_musl` for x86_64 systems or `latest-aarch64_musl` for ARM64 systems for best compatibility.
## 🛡️ Security Considerations
- **Firewall**: Only expose port 7666 to trusted networks
-- **Encryption**: Use the encryption features in client/server tools
-- **Access Control**: Implement service key management strategy
+- **Authentication**: Keep `admin.key` on the relay and distribute expiring `pbmt1_` temporary credentials to workloads
+- **Forwarded payload encryption**: Use `register --codec` when the inner application protocol is plaintext
- **Updates**: Regularly update to the latest version for security patches
## 📊 Monitoring and Logs
diff --git a/README.md b/README.md
index 3745f7c..e94280c 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
-
+
@@ -27,6 +27,8 @@
## Highlights
- **One binary, one public port** — `pb-mapper` provides every runtime role, while a service-key registry replaces per-service port planning.
+- **Scoped temporary credentials** — the administrator key can issue renewable, expiring `pbmt1_` credentials; each credential gets an isolated service namespace and can only inspect, register, and connect inside it.
+- **Authenticated protocol v2** — directional AES-256-GCM control frames authenticate in the first request without adding a handshake round trip. New clients use v2; the server can temporarily allow legacy clients during migration.
- **Optional encryption** — AES-256-GCM (via `ring`) on forwarded traffic, enabled with `--codec` at registration.
- **Proven in production** — on real workloads (e.g. a Palworld UDP server), latency matches frp with a directly exposed port.
@@ -41,18 +43,21 @@ With an AI coding agent (Claude Code, Cursor, Kiro), the built-in skills handle
### Alternative — one-liner install script
-If the remote host can reach GitHub directly, this installs the unified `pb-mapper` binary and runs its `server` command as a systemd service on Linux (x86_64, musl) — port `7666`, `--use-machine-msg-header-key` on, key stored at `/var/lib/pb-mapper-server/msg_header_key`.
+If the remote host can reach GitHub directly, this installs the unified `pb-mapper` binary and runs its `server` command as a systemd service on Linux (x86_64, musl). The relay listens on port `7666` and creates a random administrator key at `/var/lib/pb-mapper/auth/admin.key` on first start.
```bash
curl -fsSL https://raw.githubusercontent.com/acking-you/pb-mapper/master/scripts/install-server-github.sh | bash
```
-After install, load the same key before running `pb-mapper register` or `pb-mapper connect`:
+Use the administrator key only for management and issue a temporary credential for a workload:
```bash
-export MSG_HEADER_KEY="$(cat /var/lib/pb-mapper-server/msg_header_key)"
+export MSG_HEADER_KEY="$(sudo cat /var/lib/pb-mapper/auth/admin.key)"
+pb-mapper admin --server :7666 key issue --ttl 24h --label home-web
```
+Copy the printed `pbmt1_...` credential to the register and connect machines as their `MSG_HEADER_KEY`. They may use the same service name without colliding with another temporary credential's namespace.
+
## Architecture

@@ -80,10 +85,13 @@ Your web server runs on `localhost:8080` at home.
# 1. on the public server — start the central router
pb-mapper server --port 7666
-# 2. at home — register the web server under key 'web'
+# 2. issue a temporary credential, then export it on both endpoint machines
+export MSG_HEADER_KEY=''
+
+# 3. at home — register the web server under key 'web'
pb-mapper register tcp --server :7666 --key web --addr 127.0.0.1:8080
-# 3. at the coffee shop — subscribe and expose it locally
+# 4. at the coffee shop — subscribe and expose it locally
pb-mapper connect tcp --server :7666 --key web --addr 127.0.0.1:3000
```
@@ -97,25 +105,27 @@ Open `http://localhost:3000` in the coffee-shop browser — traffic flows throug
| `pb-mapper register tcp\|udp` | Registers a local TCP/UDP service with the server |
| `pb-mapper connect tcp\|udp` | Subscribes to a registered service and exposes a local port |
| `pb-mapper status keys\|remote-id` | Queries the central router |
+| `pb-mapper admin ...` | Issues/renews/revokes credentials and inspects auth, services, and connections |
| **Flutter UI** (`ui/`) | GUI for server, register, connect, and status workflows |
## Developer view
-- **Rust core** — the unified entry point is `src/bin/pb-mapper.rs`; shared protocol and networking live in `src/common` and `src/utils`; server / register / connect internals live in `src/pb_server`, `src/local/server`, and `src/local/client`.
+- **Rust core** — a workspace under `crates/`, layered bottom-up: `pb-mapper-core` (credentials, checksum, config, addressing), `pb-mapper-auth` (credential lifecycle and persistence), `pb-mapper-protocol` (framing and secure sessions), then `pb-mapper-server` and `pb-mapper-client` as peers, with the `pb-mapper` binary in `pb-mapper-cli`.
- **Flutter UI** — views in `ui/lib/src/views`, FFI layers in `ui/lib/src/ffi`, Rust bridge in `ui/native/pb_mapper_ffi`. FFI calls run on a background isolate, and Rust returns JSON (`{success, message, data}`) to keep the C ABI stable.
## Documentation
- User guide (build / run / use): [`docs/user-guide.md`](docs/user-guide.md)
+- Authentication and protocol v2: [`docs/authentication-v2.md`](docs/authentication-v2.md)
- Docker server guide: [`DOCKER_README.md`](DOCKER_README.md)
- 中文文档: [`README.zh-CN.md`](README.zh-CN.md), [`docs/user-guide.zh-CN.md`](docs/user-guide.zh-CN.md)
## Repository layout
-- `src/` — Rust backend
+- `crates/` — the Rust workspace (six crates; the root manifest is virtual)
- `ui/` — Flutter UI + native bridge
- `docs/` — documentation and assets
-- `docker/`, `services/`, `scripts/`, `tests/` — deployment and tooling
+- `docker/`, `services/`, `scripts/` — deployment and tooling
- `skills/` — AI coding agent deployment skills (server and connect tunnel)
## License
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 58fe928..562351e 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -3,7 +3,7 @@
-
+
@@ -27,6 +27,8 @@
## 亮点
- **单二进制、单公网端口**:统一的 `pb-mapper` 命令覆盖所有运行角色,服务 key 注册表取代逐个服务规划端口。
+- **临时凭据与命名空间隔离**:管理员密钥可签发可续期、自动过期的 `pbmt1_` 凭据;每把临时凭据只能查看、注册和连接自己的命名空间。
+- **V2 首帧鉴权**:控制帧使用按方向派生的 AES-256-GCM 密钥,在第一个请求内完成鉴权,不增加额外握手往返;新客户端固定使用 V2,服务端可在迁移期兼容旧协议。
- **可选加密**:转发流量可启用 AES-256-GCM(基于 `ring`),注册服务时用 `--codec` 开启。
- **生产可用**:真实负载下(例如 Palworld UDP 服务器),延迟与 frp 直暴端口相当。
@@ -41,18 +43,21 @@
### 备选方式:一键安装脚本
-远程主机能直连 GitHub 时,一条命令即可在 Linux(x86_64,musl)上安装统一的 `pb-mapper` 二进制,并以 `server` 子命令启动 systemd 服务:端口 `7666`,启用 `--use-machine-msg-header-key`,key 落盘在 `/var/lib/pb-mapper-server/msg_header_key`。
+远程主机能直连 GitHub 时,一条命令即可在 Linux(x86_64,musl)上安装统一的 `pb-mapper` 二进制,并以 `server` 子命令启动 systemd 服务。中继监听 `7666`,首次启动时会在 `/var/lib/pb-mapper/auth/admin.key` 创建随机管理员密钥。
```bash
curl -fsSL https://raw.githubusercontent.com/acking-you/pb-mapper/master/scripts/install-server-github.sh | bash
```
-安装完成后,在运行 `pb-mapper register` 或 `pb-mapper connect` 前加载同一把 key:
+管理员密钥只用于管理;先为一项业务签发临时凭据:
```bash
-export MSG_HEADER_KEY="$(cat /var/lib/pb-mapper-server/msg_header_key)"
+export MSG_HEADER_KEY="$(sudo cat /var/lib/pb-mapper/auth/admin.key)"
+pb-mapper admin --server :7666 key issue --ttl 24h --label home-web
```
+把输出的 `pbmt1_...` 凭据作为 register 与 connect 机器上的 `MSG_HEADER_KEY`。不同临时凭据即使使用相同的 service name,也不会相互冲突。
+
## 架构

@@ -80,10 +85,13 @@ register 与 connect 工作流也可以通过 Flutter UI 操作。
# 1. 公网服务器:启动中心路由
pb-mapper server --port 7666
-# 2. 家中机器:以 key 'web' 注册服务
+# 2. 签发临时凭据,并在两端机器导入
+export MSG_HEADER_KEY=''
+
+# 3. 家中机器:以 key 'web' 注册服务
pb-mapper register tcp --server :7666 --key web --addr 127.0.0.1:8080
-# 3. 咖啡店机器:订阅并在本地暴露
+# 4. 咖啡店机器:订阅并在本地暴露
pb-mapper connect tcp --server :7666 --key web --addr 127.0.0.1:3000
```
@@ -97,25 +105,27 @@ pb-mapper connect tcp --server :7666 --key web --addr 127.0.0.1:3000
| `pb-mapper register tcp\|udp` | 将本地 TCP/UDP 服务注册到服务器 |
| `pb-mapper connect tcp\|udp` | 订阅已注册的服务并在本地暴露端口 |
| `pb-mapper status keys\|remote-id` | 查询中心路由状态 |
+| `pb-mapper admin ...` | 签发/续期/吊销临时凭据并查看认证、服务与连接状态 |
| **Flutter UI**(`ui/`) | server、register、connect、status 的图形化界面 |
## 开发者视角
-- **Rust 核心**:统一入口为 `src/bin/pb-mapper.rs`;协议与网络通用逻辑在 `src/common`、`src/utils`;server/register/connect 实现在 `src/pb_server`、`src/local/server`、`src/local/client`。
+- **Rust 核心**:`crates/` 下的 workspace,自底向上分层:`pb-mapper-core`(凭据、校验和、配置、地址解析)→ `pb-mapper-auth`(凭据生命周期与持久化)→ `pb-mapper-protocol`(帧格式与安全会话)→ `pb-mapper-server` 与 `pb-mapper-client`(二者平级,互不引用)→ `pb-mapper-cli`(`pb-mapper` 二进制所在)。
- **Flutter UI**:界面在 `ui/lib/src/views`,FFI 各层在 `ui/lib/src/ffi`,Rust 桥接在 `ui/native/pb_mapper_ffi`。FFI 调用跑在后台 isolate,Rust 统一返回 JSON(`{success, message, data}`)以保持 C ABI 稳定。
## 文档
- 使用手册(编译/运行/使用):[`docs/user-guide.zh-CN.md`](docs/user-guide.zh-CN.md)
+- 认证与 V2 协议:[`docs/authentication-v2.zh-CN.md`](docs/authentication-v2.zh-CN.md)
- Docker 服务器指南:[`DOCKER_README.md`](DOCKER_README.md)
- English docs: [`README.md`](README.md)、[`docs/user-guide.md`](docs/user-guide.md)
## 仓库结构
-- `src/` — Rust 后端
+- `crates/` — Rust workspace(六个 crate,根清单为虚拟清单)
- `ui/` — Flutter UI + 原生桥接
- `docs/` — 文档与素材
-- `docker/`、`services/`、`scripts/`、`tests/` — 部署与工具
+- `docker/`、`services/`、`scripts/` — 部署与工具
- `skills/` — AI 编程助手部署 skill(服务端、客户端隧道)
## 许可证
diff --git a/clippy.toml b/clippy.toml
new file mode 100644
index 0000000..96ba758
--- /dev/null
+++ b/clippy.toml
@@ -0,0 +1,5 @@
+# `unwrap_used` and `expect_used` are denied workspace-wide (see the root
+# `[workspace.lints.clippy]`). A panic in a test is a failing test, which is the
+# point, so exempt test code rather than annotating every assertion.
+allow-unwrap-in-tests = true
+allow-expect-in-tests = true
diff --git a/crates/pb-mapper-auth/Cargo.toml b/crates/pb-mapper-auth/Cargo.toml
new file mode 100644
index 0000000..a76d7a3
--- /dev/null
+++ b/crates/pb-mapper-auth/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "pb-mapper-auth"
+version.workspace = true
+edition.workspace = true
+authors.workspace = true
+
+[dependencies]
+pb-mapper-core.workspace = true
+
+parking_lot.workspace = true
+rand.workspace = true
+ring.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+subtle.workspace = true
+tokio.workspace = true
+tokio-util.workspace = true
+tracing.workspace = true
+
+[lints]
+workspace = true
diff --git a/crates/pb-mapper-auth/src/actor/epoch.rs b/crates/pb-mapper-auth/src/actor/epoch.rs
new file mode 100644
index 0000000..c69ba8a
--- /dev/null
+++ b/crates/pb-mapper-auth/src/actor/epoch.rs
@@ -0,0 +1,142 @@
+//! Root rotation, auth-state reset, and live temporary-key wipe.
+use super::super::*;
+use super::{audit, ensure_store_available};
+
+fn remember_previous_root(inner: &AuthStateInner) {
+ *inner.previous_root.write() = Some(PreviousRoot {
+ admin_key: inner.admin_key(),
+ instance_id: inner.instance_id(),
+ });
+}
+
+pub(super) fn actor_reset(
+ inner: &Arc,
+ config: &AuthConfig,
+ leases: &mut Leases,
+ admin_replays: &VecDeque,
+ action: &str,
+) -> Result<(), AuthFailure> {
+ let new_instance_id = random_instance_id();
+ inner.root_epoch.fetch_add(1, Ordering::AcqRel);
+ let reset_audit = audit(action, None, None);
+ let mut snapshot = empty_snapshot(inner, new_instance_id, admin_replays);
+ push_persisted_audit(&mut snapshot.audit_records, reset_audit.clone());
+ let admin_key = inner.admin_key();
+ let next_instance_path = config.state_dir.join("server-instance-id.next");
+ if let Err(error) = atomic_write(&next_instance_path, &new_instance_id, 0o600)
+ .and_then(|()| write_snapshot_and_truncate_wal(config, &admin_key, &snapshot))
+ .and_then(|()| {
+ atomic_write(
+ &config.state_dir.join("server-instance-id"),
+ &new_instance_id,
+ 0o600,
+ )
+ })
+ {
+ if !reset_already_installed(&config.state_dir, &admin_key, &new_instance_id) {
+ inner.safe_mode.store(true, Ordering::Release);
+ cancel_all_temporary_leases(inner);
+ return Err(error);
+ }
+ tracing::warn!(
+ event = "auth_state_reset_finalized_after_sync_error",
+ error = %error,
+ "server-instance-id replacement reported an error, but the live id and snapshot already match the new instance; finishing in-memory reset"
+ );
+ }
+ let _ = std::fs::remove_file(&next_instance_path);
+ push_audit_record(inner, reset_audit);
+ remember_previous_root(inner);
+ leases.wipe(unix_seconds());
+ *inner.instance_id.write() = new_instance_id;
+ inner.safe_mode.store(false, Ordering::Release);
+ Ok(())
+}
+
+pub(super) fn actor_rotate_root(
+ inner: &Arc,
+ config: &AuthConfig,
+ leases: &mut Leases,
+ admin_lease: &mut Arc,
+ new_key: AesKeyType,
+) -> Result<(), AuthFailure> {
+ if new_key == inner.admin_key() {
+ return Err(AuthFailure::new(
+ "administrator_key_unchanged",
+ "new administrator key must differ from the current key",
+ false,
+ ));
+ }
+ if !is_env_safe_admin_key(&new_key) {
+ return Err(AuthFailure::new(
+ "administrator_key_invalid",
+ env_safe_admin_key_error(),
+ false,
+ ));
+ }
+ // Unreachable: `is_env_safe_admin_key` above accepts only printable ASCII.
+ // Reported rather than panicked, since this already returns `Result`.
+ let new_key_string = String::from_utf8(new_key.to_vec()).map_err(|_| {
+ AuthFailure::new(
+ "administrator_key_invalid",
+ env_safe_admin_key_error(),
+ false,
+ )
+ })?;
+
+ inner.root_epoch.fetch_add(1, Ordering::AcqRel);
+ let rotate_audit = audit("administrator_key_rotate", None, None);
+ let mut snapshot = empty_snapshot(inner, inner.instance_id(), &VecDeque::new());
+ push_persisted_audit(&mut snapshot.audit_records, rotate_audit.clone());
+ let next_key_path = config.state_dir.join("admin.key.next");
+ if let Err(error) = write_admin_key_file(&next_key_path, &new_key_string, true)
+ .and_then(|()| write_snapshot_and_truncate_wal(config, &new_key, &snapshot))
+ .and_then(|()| write_admin_key(&config.state_dir, &new_key_string))
+ {
+ if !rotation_already_installed(&config.state_dir, &new_key_string) {
+ inner.safe_mode.store(true, Ordering::Release);
+ cancel_all_temporary_leases(inner);
+ return Err(error);
+ }
+ tracing::warn!(
+ event = "administrator_key_rotate_finalized_after_sync_error",
+ error = %error,
+ "admin.key replacement reported an error, but the new snapshot already decrypts with the new key; finishing in-memory rotation"
+ );
+ }
+ let _ = std::fs::remove_file(&next_key_path);
+ push_audit_record(inner, rotate_audit);
+ remember_previous_root(inner);
+ leases.wipe(unix_seconds());
+ let old_admin_lease = admin_lease.clone();
+ let new_admin_lease = Arc::new(AuthLease::new(ADMIN_KEY_ID, u64::MAX));
+ *inner.admin.write() = AdminState {
+ key: new_key,
+ lease: Arc::downgrade(&new_admin_lease),
+ };
+ if inner.sync_process_credential {
+ set_process_msg_header_key(Some(&new_key_string)).map_err(AuthFailure::internal)?;
+ }
+ inner.safe_mode.store(false, Ordering::Release);
+ old_admin_lease.cancel_rotated();
+ *admin_lease = new_admin_lease;
+ Ok(())
+}
+
+pub(super) fn actor_set_legacy_protocol(
+ inner: &Arc,
+ config: &AuthConfig,
+ policy: LegacyProtocolPolicy,
+) -> Result<(), AuthFailure> {
+ ensure_store_available(inner)?;
+ append_mutation(
+ config,
+ inner,
+ StateMutation::LegacyProtocol(policy),
+ audit("legacy_protocol_update", None, Some(format!("{policy:?}"))),
+ )?;
+ inner
+ .legacy_protocol_allowed
+ .store(policy.is_allowed(), Ordering::Release);
+ Ok(())
+}
diff --git a/crates/pb-mapper-auth/src/actor/lifecycle.rs b/crates/pb-mapper-auth/src/actor/lifecycle.rs
new file mode 100644
index 0000000..ee3fa89
--- /dev/null
+++ b/crates/pb-mapper-auth/src/actor/lifecycle.rs
@@ -0,0 +1,432 @@
+//! Issue, inspect, renew, revoke, and collect temporary keys.
+use super::super::*;
+use super::{
+ audit, ensure_store_available, key_not_active, key_not_found, key_not_renewable,
+ slot_state_name, validate_slot_identity,
+};
+
+fn validate_ttl(config: &AuthConfig, ttl: Duration) -> Result {
+ if ttl < MIN_TEMP_KEY_TTL {
+ return Err(AuthFailure::new(
+ "temporary_key_ttl_too_short",
+ format!(
+ "temporary key TTL must be at least {} seconds",
+ MIN_TEMP_KEY_TTL.as_secs()
+ ),
+ false,
+ ));
+ }
+ if ttl > config.max_temporary_key_ttl {
+ return Err(AuthFailure::new(
+ "temporary_key_ttl_too_long",
+ format!(
+ "temporary key TTL exceeds the configured maximum of {} seconds",
+ config.max_temporary_key_ttl.as_secs()
+ ),
+ false,
+ ));
+ }
+ Ok(unix_seconds().saturating_add(ttl.as_secs()))
+}
+
+fn validate_label(label: Option) -> Result, AuthFailure> {
+ let label = label
+ .map(|label| label.trim().to_string())
+ .filter(|label| !label.is_empty());
+ if label.as_ref().is_some_and(|label| label.len() > 64) {
+ return Err(AuthFailure::new(
+ "temporary_key_label_too_long",
+ "temporary key label must not exceed 64 UTF-8 bytes",
+ false,
+ ));
+ }
+ Ok(label)
+}
+
+/// One key's lifecycle, read from wherever that key lives.
+struct KeyState {
+ state: SlotState,
+ expires_at: u64,
+ issued_at: u64,
+ label: Option,
+}
+
+/// Reads a key's lifecycle from the slot table, falling back to the entries
+/// retained for slots the configured capacity no longer covers. Every operation
+/// that accepts any live key id needs both paths; see
+/// `AuthStateInner::high_slot_generations`.
+fn key_state(inner: &AuthStateInner, key_id: KeyId) -> Result {
+ let slots = inner.slots();
+ if let Some(slot) = slots.get(key_id.slot().as_index()) {
+ validate_slot_identity(slot, key_id)?;
+ let metadata = inner
+ .cold()
+ .get(&key_id)
+ .cloned()
+ .ok_or_else(|| key_not_found(key_id))?;
+ return Ok(KeyState {
+ state: slot.state,
+ expires_at: slot.expires_at,
+ issued_at: metadata.issued_at,
+ label: metadata.label.clone(),
+ });
+ }
+ drop(slots);
+ let high = inner.high();
+ let entry = high_slot_entry(&high, key_id)?;
+ Ok(KeyState {
+ state: entry.state,
+ expires_at: entry.expires_at,
+ issued_at: entry.issued_at,
+ label: entry.label.clone(),
+ })
+}
+
+pub(super) fn actor_issue(
+ inner: &Arc,
+ config: &AuthConfig,
+ leases: &mut Leases,
+ ttl: Duration,
+ label: Option,
+) -> Result {
+ ensure_store_available(inner)?;
+ let expires_at = validate_ttl(config, ttl)?;
+ let label = validate_label(label)?;
+ let issued_at = unix_seconds();
+ let (index, generation, key_id, entry) = {
+ let slots = inner.slots();
+ // A row whose generation cannot advance is skipped rather than reused: it
+ // has no unused identity left to hand out.
+ let Some((slot_index, generation)) = slots.iter().enumerate().find_map(|(index, slot)| {
+ (slot.state == SlotState::Free)
+ .then(|| slot.generation.next())
+ .flatten()
+ .map(|generation| (SlotIndex::from_index(index), generation))
+ }) else {
+ return Err(AuthFailure::new(
+ "temporary_key_capacity_exhausted",
+ "temporary key slot table is full",
+ true,
+ ));
+ };
+ let key_id = KeyId::new(generation, slot_index);
+ (
+ slot_index.as_index(),
+ generation,
+ key_id,
+ PersistedEntry {
+ key_id,
+ state: SlotState::Active,
+ issued_at,
+ expires_at,
+ label: label.clone(),
+ tombstoned_at: None,
+ },
+ )
+ };
+ // Persist before taking the slot write lock. A fail-closed WAL error
+ // cancels leases via slots.read() and must not nest under slots.write().
+ append_mutation(
+ config,
+ inner,
+ StateMutation::Issue(entry),
+ audit("temporary_key_issue", Some(key_id), label.clone()),
+ )?;
+ let mut slots = inner.slots_mut();
+ let slot = slots
+ .get_mut(index)
+ .ok_or_else(|| AuthFailure::internal("issued slot disappeared"))?;
+ let lease = Arc::new(AuthLease::new(key_id, expires_at));
+ slot.generation = generation;
+ slot.state = SlotState::Active;
+ slot.expires_at = expires_at;
+ slot.lease = Arc::downgrade(&lease);
+ drop(slots);
+ leases.issue(&lease, issued_at, label);
+ metadata_with_credential(inner, key_id, true)
+}
+
+pub(super) fn actor_list(
+ inner: &Arc,
+ page: u32,
+ page_size: u16,
+) -> Result {
+ let page_size = page_size.clamp(1, 1000) as usize;
+ let start = (page as usize).saturating_mul(page_size);
+ let slots = inner.slots();
+ let cold = inner.cold();
+ let mut all = slots
+ .iter()
+ .enumerate()
+ .filter_map(|(index, slot)| {
+ if slot.state == SlotState::Free {
+ return None;
+ }
+ let key_id = KeyId::new(slot.generation, SlotIndex::from_index(index));
+ let cold = cold.get(&key_id)?;
+ Some(TemporaryKeyMetadata {
+ key_id,
+ state: slot_state_name(slot.state).to_string(),
+ issued_at: cold.issued_at,
+ expires_at: slot.expires_at,
+ label: cold.label.clone(),
+ })
+ })
+ .collect::>();
+ all.extend(
+ inner
+ .high()
+ .iter()
+ .filter(|entry| entry.state != SlotState::Free)
+ .map(high_slot_metadata),
+ );
+ all.sort_by_key(|item| std::cmp::Reverse(item.issued_at));
+ let items = all.iter().skip(start).take(page_size).cloned().collect();
+ let next_page = (start.saturating_add(page_size) < all.len()).then_some(page.saturating_add(1));
+ Ok(KeyPage {
+ schema_version: 1,
+ items,
+ next_page,
+ })
+}
+
+pub(super) fn actor_show(
+ inner: &Arc,
+ config: &AuthConfig,
+ key_id: KeyId,
+ reveal: bool,
+) -> Result {
+ let result = metadata_with_credential(inner, key_id, reveal)?;
+ append_audit(
+ config,
+ inner,
+ audit(
+ if reveal {
+ "temporary_key_reveal"
+ } else {
+ "temporary_key_show"
+ },
+ Some(key_id),
+ result.metadata.label.clone(),
+ ),
+ )?;
+ Ok(result)
+}
+
+pub(super) fn actor_renew(
+ inner: &Arc,
+ config: &AuthConfig,
+ leases: &mut Leases,
+ key_id: KeyId,
+ ttl: Duration,
+) -> Result {
+ ensure_store_available(inner)?;
+ let expires_at = validate_ttl(config, ttl)?;
+ let index = key_id.slot().as_index();
+ let current = key_state(inner, key_id)?;
+ if current.state != SlotState::Active || current.expires_at <= unix_seconds() {
+ return Err(key_not_renewable());
+ }
+ let label = current.label;
+ append_mutation(
+ config,
+ inner,
+ StateMutation::Renew { key_id, expires_at },
+ audit("temporary_key_renew", Some(key_id), label.clone()),
+ )?;
+ let mut slots = inner.slots_mut();
+ if let Some(slot) = slots.get_mut(index) {
+ validate_slot_identity(slot, key_id)?;
+ if slot.state != SlotState::Active {
+ return Err(AuthFailure::new(
+ "temporary_key_inactive",
+ "temporary key lease is no longer active",
+ true,
+ ));
+ }
+ slot.expires_at = expires_at;
+ match slot.lease.upgrade() {
+ Some(lease) if !lease.cancellation_token().is_cancelled() => {
+ lease.expires_at.store(expires_at, Ordering::Release);
+ drop(slots);
+ leases.renew(key_id, expires_at);
+ }
+ // A cancelled lease cannot be revived, so the renewal installs a
+ // replacement; that drops the handle on the lease it succeeds.
+ _ => {
+ let lease = Arc::new(AuthLease::new(key_id, expires_at));
+ slot.lease = Arc::downgrade(&lease);
+ drop(slots);
+ leases.adopt(&lease);
+ }
+ }
+ return metadata_with_credential(inner, key_id, true);
+ }
+ drop(slots);
+ {
+ let mut high = inner.high_mut();
+ let entry = high_slot_entry_mut(&mut high, key_id)?;
+ if entry.state != SlotState::Active {
+ return Err(AuthFailure::new(
+ "temporary_key_inactive",
+ "temporary key lease is no longer active",
+ true,
+ ));
+ }
+ entry.expires_at = expires_at;
+ entry.tombstoned_at = None;
+ }
+ metadata_with_credential(inner, key_id, true)
+}
+
+pub(super) fn actor_revoke(
+ inner: &Arc,
+ config: &AuthConfig,
+ leases: &mut Leases,
+ key_id: KeyId,
+) -> Result {
+ ensure_store_available(inner)?;
+ let now = unix_seconds();
+ let index = key_id.slot().as_index();
+ let current = key_state(inner, key_id)?;
+ if current.state != SlotState::Active {
+ return Err(key_not_active());
+ }
+ let KeyState {
+ label,
+ issued_at,
+ expires_at,
+ ..
+ } = current;
+ append_mutation(
+ config,
+ inner,
+ StateMutation::Revoke { key_id, at: now },
+ audit("temporary_key_revoke", Some(key_id), label.clone()),
+ )?;
+ let mut slots = inner.slots_mut();
+ if let Some(slot) = slots.get_mut(index) {
+ validate_slot_identity(slot, key_id)?;
+ slot.state = SlotState::Revoked;
+ if let Some(lease) = slot.lease.upgrade() {
+ lease.cancel_revoked();
+ }
+ let state = slot_state_name(slot.state).to_string();
+ let expires_at = slot.expires_at;
+ drop(slots);
+ // Retire only: the row stays until its retention elapses, because the
+ // slot table holds a `Weak` and a later request has to be able to read
+ // the revoked reason rather than find a recycled row.
+ leases.retire_now(key_id);
+ let metadata = inner
+ .cold()
+ .get(&key_id)
+ .cloned()
+ .ok_or_else(|| key_not_found(key_id))?;
+ return Ok(TemporaryKeyMetadata {
+ key_id,
+ state,
+ issued_at: metadata.issued_at,
+ expires_at,
+ label: metadata.label.clone(),
+ });
+ }
+ drop(slots);
+ let mut high = inner.high_mut();
+ let entry = high_slot_entry_mut(&mut high, key_id)?;
+ if entry.state != SlotState::Active {
+ return Err(key_not_active());
+ }
+ entry.state = SlotState::Revoked;
+ entry.tombstoned_at = Some(now);
+ let state = slot_state_name(entry.state).to_string();
+ drop(high);
+ leases.retire_now(key_id);
+ Ok(TemporaryKeyMetadata {
+ key_id,
+ state,
+ issued_at,
+ expires_at,
+ label,
+ })
+}
+
+pub(super) fn actor_gc(
+ inner: &Arc,
+ config: &AuthConfig,
+ leases: &mut Leases,
+ admin_replays: &VecDeque,
+) -> Result {
+ ensure_store_available(inner)?;
+ let removed = leases.collect_garbage(unix_seconds());
+ let gc_audit = audit("temporary_key_gc", None, Some(format!("removed={removed}")));
+ let mut snapshot = build_snapshot(inner, admin_replays);
+ push_persisted_audit(&mut snapshot.audit_records, gc_audit.clone());
+ let admin_key = inner.admin_key();
+ if let Err(error) = write_snapshot_and_truncate_wal(config, &admin_key, &snapshot) {
+ inner.safe_mode.store(true, Ordering::Release);
+ cancel_all_temporary_leases(inner);
+ return Err(error);
+ }
+ push_audit_record(inner, gc_audit);
+ Ok(removed)
+}
+
+fn high_slot_entry(high: &[PersistedEntry], key_id: KeyId) -> Result<&PersistedEntry, AuthFailure> {
+ high.iter()
+ .find(|entry| entry.key_id == key_id)
+ .ok_or_else(|| key_not_found(key_id))
+}
+
+fn high_slot_entry_mut(
+ high: &mut [PersistedEntry],
+ key_id: KeyId,
+) -> Result<&mut PersistedEntry, AuthFailure> {
+ high.iter_mut()
+ .find(|entry| entry.key_id == key_id)
+ .ok_or_else(|| key_not_found(key_id))
+}
+
+fn high_slot_metadata(entry: &PersistedEntry) -> TemporaryKeyMetadata {
+ TemporaryKeyMetadata {
+ key_id: entry.key_id,
+ state: slot_state_name(entry.state).to_string(),
+ issued_at: entry.issued_at,
+ expires_at: entry.expires_at,
+ label: entry.label.clone(),
+ }
+}
+
+fn metadata_with_credential(
+ inner: &Arc,
+ key_id: KeyId,
+ reveal: bool,
+) -> Result {
+ let slots = inner.slots();
+ let credential = if reveal {
+ let key = derive_temporary_key(&inner.admin_key(), &inner.instance_id(), key_id)?;
+ encode_temporary_credential(key_id.as_u64(), &key)
+ } else {
+ String::new()
+ };
+ if let Some(slot) = slots.get(key_id.slot().as_index()) {
+ validate_slot_identity(slot, key_id)?;
+ let cold = inner.cold();
+ let cold = cold.get(&key_id).ok_or_else(|| key_not_found(key_id))?;
+ return Ok(IssuedTemporaryKey {
+ metadata: TemporaryKeyMetadata {
+ key_id,
+ state: slot_state_name(slot.state).to_string(),
+ issued_at: cold.issued_at,
+ expires_at: slot.expires_at,
+ label: cold.label.clone(),
+ },
+ credential,
+ });
+ }
+ let high = inner.high();
+ Ok(IssuedTemporaryKey {
+ metadata: high_slot_metadata(high_slot_entry(&high, key_id)?),
+ credential,
+ })
+}
diff --git a/crates/pb-mapper-auth/src/actor/mod.rs b/crates/pb-mapper-auth/src/actor/mod.rs
new file mode 100644
index 0000000..2945614
--- /dev/null
+++ b/crates/pb-mapper-auth/src/actor/mod.rs
@@ -0,0 +1,408 @@
+//! Serialized owner of mutable authentication lifecycle state.
+//!
+//! ```text
+//! authenticated admin command
+//! |
+//! v
+//! validate current admin lease
+//! |
+//! v
+//! append encrypted WAL -> mutate slots / leases / timing wheel
+//! |
+//! +-> periodic snapshot + bounded replay/audit retention
+//! ```
+//!
+//! Keeping authorization revalidation and mutations in one actor prevents a request
+//! authenticated before root rotation from executing against the new administrator
+//! state. The actor is also the sole strong owner of temporary-key leases.
+
+use super::*;
+
+mod epoch;
+mod lifecycle;
+use epoch::*;
+use lifecycle::*;
+
+pub(super) struct AuthActorState {
+ leases: Leases,
+ admin_replays: HashSet<[u8; 32]>,
+ admin_replay_order: VecDeque,
+}
+
+impl AuthActorState {
+ pub(super) fn new(
+ leases: Leases,
+ admin_replays: HashSet<[u8; 32]>,
+ admin_replay_order: VecDeque,
+ ) -> Self {
+ Self {
+ leases,
+ admin_replays,
+ admin_replay_order,
+ }
+ }
+}
+
+pub(super) async fn run_auth_actor(
+ inner: Arc,
+ mut admin_lease: Arc,
+ mut command_rx: mpsc::Receiver,
+ config: AuthConfig,
+ state: AuthActorState,
+ _state_lock: Arc,
+) {
+ let AuthActorState {
+ mut leases,
+ mut admin_replays,
+ mut admin_replay_order,
+ } = state;
+ let mut last_snapshot_at = unix_seconds();
+ let mut tick = tokio::time::interval(Duration::from_secs(1));
+ tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+ loop {
+ tokio::select! {
+ _ = tick.tick() => {
+ let now = unix_seconds();
+ leases.tick(now);
+ prune_expired_admin_replays(
+ now,
+ &mut admin_replays,
+ &mut admin_replay_order,
+ );
+ // WHY: A failed load starts safe mode with empty in-memory
+ // generations. Compacting that reconstruction would replace the
+ // damaged snapshot, truncate the WAL, and let the next start
+ // exit safe mode without rotating the instance id.
+ if compaction_is_allowed(inner.safe_mode.load(Ordering::Acquire))
+ && now.saturating_sub(last_snapshot_at)
+ >= SNAPSHOT_COMPACTION_INTERVAL.as_secs()
+ {
+ let snapshot = build_snapshot(&inner, &admin_replay_order);
+ if let Err(error) = write_snapshot_and_truncate_wal(
+ &config,
+ &inner.admin_key(),
+ &snapshot,
+ ) {
+ inner.safe_mode.store(true, Ordering::Release);
+ cancel_all_temporary_leases(&inner);
+ tracing::error!(
+ event = "auth_state_safe_mode",
+ auth_stage = "snapshot_compaction",
+ reason = %error.code,
+ error = %error,
+ "authentication state compaction failed closed"
+ );
+ } else {
+ last_snapshot_at = now;
+ }
+ }
+ }
+ command = command_rx.recv() => {
+ let Some(command) = command else {
+ admin_lease.cancel_rotated();
+ cancel_all_temporary_leases(&inner);
+ break;
+ };
+ match command {
+ AuthCommand::ClaimAdminMutation {
+ authority,
+ fingerprint,
+ client_timestamp,
+ response,
+ } => {
+ let result = validate_admin_authority(&inner, &authority).and_then(|()| {
+ actor_claim_admin_mutation(
+ &inner,
+ &config,
+ &mut admin_replays,
+ &mut admin_replay_order,
+ fingerprint,
+ client_timestamp,
+ )
+ });
+ let _ = response.send(result);
+ }
+ AuthCommand::Issue { authority, ttl, label, response } => {
+ let result = validate_admin_authority(&inner, &authority)
+ .and_then(|()| actor_issue(&inner, &config, &mut leases, ttl, label));
+ let _ = response.send(result);
+ }
+ AuthCommand::List { authority, page, page_size, response } => {
+ let result = validate_admin_authority(&inner, &authority)
+ .and_then(|()| actor_list(&inner, page, page_size));
+ let _ = response.send(result);
+ }
+ AuthCommand::Show { authority, key_id, reveal, response } => {
+ let result = validate_admin_authority(&inner, &authority)
+ .and_then(|()| actor_show(&inner, &config, key_id, reveal));
+ let _ = response.send(result);
+ }
+ AuthCommand::Renew { authority, key_id, ttl, response } => {
+ let result = validate_admin_authority(&inner, &authority)
+ .and_then(|()| actor_renew(&inner, &config, &mut leases, key_id, ttl));
+ let _ = response.send(result);
+ }
+ AuthCommand::Revoke { authority, key_id, response } => {
+ let result = validate_admin_authority(&inner, &authority)
+ .and_then(|()| actor_revoke(&inner, &config, &mut leases, key_id));
+ let _ = response.send(result);
+ }
+ AuthCommand::Gc { authority, response } => {
+ let result = validate_admin_authority(&inner, &authority)
+ .and_then(|()| actor_gc(&inner, &config, &mut leases, &admin_replay_order));
+ let _ = response.send(result);
+ }
+ AuthCommand::Reset { authority, response } => {
+ let result = validate_admin_authority(&inner, &authority)
+ .and_then(|()| actor_reset(&inner, &config, &mut leases, &admin_replay_order, "auth_state_reset"));
+ let _ = response.send(result);
+ }
+ AuthCommand::RotateRoot { authority, new_key, response } => {
+ let result = validate_admin_authority(&inner, &authority)
+ .and_then(|()| actor_rotate_root(&inner, &config, &mut leases, &mut admin_lease, new_key));
+ if result.is_ok() {
+ admin_replays.clear();
+ admin_replay_order.clear();
+ }
+ let _ = response.send(result);
+ }
+ AuthCommand::SetLegacyProtocol { authority, policy, response } => {
+ let result = validate_admin_authority(&inner, &authority)
+ .and_then(|()| actor_set_legacy_protocol(&inner, &config, policy));
+ let _ = response.send(result);
+ }
+ AuthCommand::Status { authority, response } => {
+ let result = validate_admin_authority(&inner, &authority)
+ .map(|()| actor_status(&inner));
+ let _ = response.send(result);
+ }
+ AuthCommand::Audit { authority, action, key_id, detail, response } => {
+ let result = validate_admin_authority(&inner, &authority).and_then(|()| {
+ append_audit(
+ &config,
+ &inner,
+ audit(&action, key_id, detail),
+ )
+ });
+ let _ = response.send(result);
+ }
+ AuthCommand::Shutdown { response } => {
+ admin_lease.cancel_rotated();
+ cancel_all_temporary_leases(&inner);
+ let _ = response.send(());
+ break;
+ }
+ }
+ }
+ }
+ }
+}
+
+fn actor_claim_admin_mutation(
+ inner: &AuthStateInner,
+ config: &AuthConfig,
+ admin_replays: &mut HashSet<[u8; 32]>,
+ admin_replay_order: &mut VecDeque,
+ fingerprint: [u8; 32],
+ client_timestamp: u64,
+) -> Result<(), AuthFailure> {
+ let now = unix_seconds();
+ prune_expired_admin_replays(now, admin_replays, admin_replay_order);
+ if admin_replays.contains(&fingerprint) {
+ return Err(AuthFailure::new(
+ "admin_request_replayed",
+ "administrator mutation was already admitted",
+ false,
+ ));
+ }
+ if admin_replays.len() >= ADMIN_REPLAY_CAPACITY {
+ return Err(AuthFailure::new(
+ "admin_replay_capacity_exhausted",
+ "administrator mutation replay window is full; retry after older claims expire",
+ true,
+ ));
+ }
+ if now.abs_diff(client_timestamp) > ADMIN_REPLAY_RETENTION.as_secs() / 2 {
+ return Err(AuthFailure::new(
+ "admin_request_timestamp_invalid",
+ "administrator mutation timestamp is outside the accepted window",
+ false,
+ ));
+ }
+ let record = AdminReplayRecord {
+ fingerprint,
+ client_timestamp,
+ accepted_at: now,
+ };
+ fail_closed_on_uncertain_wal(
+ inner,
+ append_wal(
+ config,
+ &inner.admin_key(),
+ &WalRecord::AdminReplay(record.clone()),
+ ),
+ )?;
+ admin_replays.insert(fingerprint);
+ admin_replay_order.push_back(record);
+ Ok(())
+}
+
+pub(super) fn prune_expired_admin_replays(
+ now: u64,
+ admin_replays: &mut HashSet<[u8; 32]>,
+ admin_replay_order: &mut VecDeque,
+) {
+ admin_replay_order.retain(|record| {
+ let keep = record.within_retention(now);
+ if !keep {
+ admin_replays.remove(&record.fingerprint);
+ }
+ keep
+ });
+}
+
+fn actor_status(inner: &Arc) -> AuthStatus {
+ let slots = inner.slots();
+ let high = inner.high();
+ let active_keys = slots
+ .iter()
+ .filter(|slot| slot.state == SlotState::Active)
+ .count()
+ + high
+ .iter()
+ .filter(|entry| entry.state == SlotState::Active)
+ .count();
+ let expired_keys = slots
+ .iter()
+ .filter(|slot| slot.state == SlotState::Expired)
+ .count()
+ + high
+ .iter()
+ .filter(|entry| entry.state == SlotState::Expired)
+ .count();
+ let revoked_keys = slots
+ .iter()
+ .filter(|slot| slot.state == SlotState::Revoked)
+ .count()
+ + high
+ .iter()
+ .filter(|entry| entry.state == SlotState::Revoked)
+ .count();
+ let last_legacy_connection_at = inner.last_legacy_connection_at.load(Ordering::Acquire);
+ AuthStatus {
+ schema_version: 1,
+ safe_mode: inner.safe_mode.load(Ordering::Acquire),
+ capacity: slots.len(),
+ active_keys,
+ expired_keys,
+ revoked_keys,
+ legacy_protocol: if inner.legacy_protocol_allowed.load(Ordering::Acquire) {
+ LegacyProtocolPolicy::Allow
+ } else {
+ LegacyProtocolPolicy::Deny
+ },
+ active_legacy_connections: inner.active_legacy_connections.load(Ordering::Acquire),
+ last_legacy_connection_at: (last_legacy_connection_at != 0)
+ .then_some(last_legacy_connection_at),
+ auth_successes: inner.auth_successes.load(Ordering::Relaxed),
+ auth_failures: inner.auth_failures.load(Ordering::Relaxed),
+ server_instance_id: hex(&inner.instance_id()),
+ }
+}
+
+fn validate_admin_authority(
+ inner: &AuthStateInner,
+ authority: &Weak,
+) -> Result<(), AuthFailure> {
+ let presented = authority.upgrade().ok_or_else(|| {
+ AuthFailure::new(
+ "administrator_key_rotated",
+ "administrator credential lease is no longer active",
+ false,
+ )
+ })?;
+ if presented.cancellation.is_cancelled() {
+ return Err(AuthFailure::new(
+ "administrator_key_rotated",
+ "administrator credential lease has been cancelled",
+ false,
+ ));
+ }
+ let current = inner.admin.read().lease.upgrade().ok_or_else(|| {
+ AuthFailure::new(
+ "administrator_key_rotated",
+ "active administrator credential lease is unavailable",
+ false,
+ )
+ })?;
+ if !Arc::ptr_eq(&presented, ¤t) {
+ return Err(AuthFailure::new(
+ "administrator_key_rotated",
+ "administrator request was authenticated before the latest root-key rotation",
+ false,
+ ));
+ }
+ Ok(())
+}
+
+fn ensure_store_available(inner: &AuthStateInner) -> Result<(), AuthFailure> {
+ if inner.safe_mode.load(Ordering::Acquire) {
+ Err(AuthFailure::new(
+ "temporary_key_store_unavailable",
+ "temporary key store is in administrator safe mode",
+ false,
+ ))
+ } else {
+ Ok(())
+ }
+}
+
+fn validate_slot_identity(slot: &SlotHot, key_id: KeyId) -> Result<(), AuthFailure> {
+ if slot.generation != key_id.generation() || slot.state == SlotState::Free {
+ Err(key_not_found(key_id))
+ } else {
+ Ok(())
+ }
+}
+
+fn key_not_found(key_id: KeyId) -> AuthFailure {
+ AuthFailure::new(
+ "temporary_key_not_found",
+ format!("temporary key {key_id} does not exist"),
+ false,
+ )
+}
+
+fn key_not_renewable() -> AuthFailure {
+ AuthFailure::new(
+ "temporary_key_not_renewable",
+ "only an active, unexpired temporary key can be renewed",
+ false,
+ )
+}
+
+fn key_not_active() -> AuthFailure {
+ AuthFailure::new(
+ "temporary_key_not_active",
+ "temporary key is not active",
+ false,
+ )
+}
+
+fn slot_state_name(state: SlotState) -> &'static str {
+ match state {
+ SlotState::Free => "free",
+ SlotState::Active => "active",
+ SlotState::Expired => "expired",
+ SlotState::Revoked => "revoked",
+ }
+}
+
+fn audit(action: &str, key_id: Option, label: Option) -> AuditRecord {
+ AuditRecord {
+ at: unix_seconds(),
+ action: action.to_string(),
+ key_id,
+ label,
+ }
+}
diff --git a/crates/pb-mapper-auth/src/config.rs b/crates/pb-mapper-auth/src/config.rs
new file mode 100644
index 0000000..e320419
--- /dev/null
+++ b/crates/pb-mapper-auth/src/config.rs
@@ -0,0 +1,189 @@
+//! Authentication configuration and platform state-directory defaults.
+use super::*;
+
+pub fn default_auth_state_dir() -> PathBuf {
+ std::env::var_os("PB_MAPPER_AUTH_STATE_DIR")
+ .map(PathBuf::from)
+ .unwrap_or_else(platform_default_auth_state_dir)
+}
+
+/// Linux systemd/Docker keep `/var/lib/pb-mapper/auth` when that path is usable
+/// (root, or an already-writable service directory). Unprivileged Linux,
+/// macOS, and Windows binaries need an application data directory instead.
+pub(crate) fn platform_default_auth_state_dir() -> PathBuf {
+ #[cfg(windows)]
+ {
+ let base = std::env::var_os("LOCALAPPDATA")
+ .or_else(|| std::env::var_os("APPDATA"))
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"));
+ base.join("pb-mapper").join("auth")
+ }
+ #[cfg(target_os = "macos")]
+ {
+ match std::env::var_os("HOME") {
+ Some(home) => PathBuf::from(home)
+ .join("Library")
+ .join("Application Support")
+ .join("pb-mapper")
+ .join("auth"),
+ None => PathBuf::from("/Library/Application Support/pb-mapper/auth"),
+ }
+ }
+ #[cfg(not(any(windows, target_os = "macos")))]
+ {
+ linux_default_auth_state_dir(
+ unix_effective_uid(),
+ linux_system_auth_dir_usable(),
+ std::env::var_os("XDG_DATA_HOME").as_deref(),
+ std::env::var_os("HOME").as_deref(),
+ )
+ }
+}
+
+#[cfg(not(any(windows, target_os = "macos")))]
+pub(crate) fn linux_default_auth_state_dir(
+ euid: u32,
+ system_dir_usable: bool,
+ xdg_data_home: Option<&std::ffi::OsStr>,
+ home: Option<&std::ffi::OsStr>,
+) -> PathBuf {
+ if euid == 0 || system_dir_usable {
+ return PathBuf::from(DEFAULT_AUTH_STATE_DIR);
+ }
+ if let Some(xdg) = xdg_data_home
+ && !xdg.is_empty()
+ {
+ return PathBuf::from(xdg).join("pb-mapper").join("auth");
+ }
+ if let Some(home) = home
+ && !home.is_empty()
+ {
+ return PathBuf::from(home)
+ .join(".local")
+ .join("share")
+ .join("pb-mapper")
+ .join("auth");
+ }
+ PathBuf::from(DEFAULT_AUTH_STATE_DIR)
+}
+
+#[cfg(not(any(windows, target_os = "macos")))]
+pub(super) fn unix_effective_uid() -> u32 {
+ unsafe extern "C" {
+ fn geteuid() -> u32;
+ }
+ unsafe { geteuid() }
+}
+
+#[cfg(not(any(windows, target_os = "macos")))]
+pub(super) fn linux_system_auth_dir_usable() -> bool {
+ let path = Path::new(DEFAULT_AUTH_STATE_DIR);
+ path.is_dir() && unix_path_is_writable(path)
+}
+
+#[cfg(not(any(windows, target_os = "macos")))]
+fn unix_path_is_writable(path: &Path) -> bool {
+ use std::os::unix::ffi::OsStrExt;
+ let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
+ return false;
+ };
+ unsafe extern "C" {
+ fn access(pathname: *const std::os::raw::c_char, mode: i32) -> i32;
+ }
+ const W_OK: i32 = 2;
+ unsafe { access(c_path.as_ptr(), W_OK) == 0 }
+}
+
+impl Default for AuthConfig {
+ fn default() -> Self {
+ Self {
+ state_dir: default_auth_state_dir(),
+ max_temporary_keys: env_usize(
+ "PB_MAPPER_AUTH_MAX_TEMP_KEYS",
+ DEFAULT_TEMP_KEY_CAPACITY,
+ 1,
+ MAX_TEMP_KEY_CAPACITY,
+ ),
+ max_temporary_key_ttl: Duration::from_secs(env_u64(
+ "PB_MAPPER_AUTH_MAX_TEMP_TTL_SECS",
+ DEFAULT_MAX_TEMP_KEY_TTL.as_secs(),
+ MIN_TEMP_KEY_TTL.as_secs(),
+ MAX_TEMP_KEY_TTL.as_secs(),
+ )),
+ legacy_protocol: legacy_protocol_from_env(),
+ }
+ }
+}
+
+fn legacy_protocol_from_env() -> LegacyProtocolPolicy {
+ match std::env::var("PB_MAPPER_LEGACY_PROTOCOL") {
+ Err(std::env::VarError::NotPresent) => LegacyProtocolPolicy::Allow,
+ Err(std::env::VarError::NotUnicode(_)) => {
+ tracing::error!(
+ event = "legacy_protocol_config_invalid",
+ "PB_MAPPER_LEGACY_PROTOCOL is not UTF-8; denying legacy framing"
+ );
+ LegacyProtocolPolicy::Deny
+ }
+ Ok(value) => parse_legacy_protocol_policy(&value).unwrap_or_else(|| {
+ tracing::error!(
+ event = "legacy_protocol_config_invalid",
+ value,
+ "PB_MAPPER_LEGACY_PROTOCOL must be `allow` or `deny`; denying legacy framing"
+ );
+ LegacyProtocolPolicy::Deny
+ }),
+ }
+}
+
+pub(super) fn parse_legacy_protocol_policy(value: &str) -> Option {
+ match value.trim().to_ascii_lowercase().as_str() {
+ "allow" => Some(LegacyProtocolPolicy::Allow),
+ "deny" => Some(LegacyProtocolPolicy::Deny),
+ _ => None,
+ }
+}
+
+fn env_usize(name: &str, default: usize, min: usize, max: usize) -> usize {
+ env_bounded(name, default, min, max)
+}
+
+fn env_u64(name: &str, default: u64, min: u64, max: u64) -> u64 {
+ env_bounded(name, default, min, max)
+}
+
+fn env_bounded(name: &str, default: T, min: T, max: T) -> T
+where
+ T: std::str::FromStr + PartialOrd + Copy + fmt::Display,
+{
+ match std::env::var(name) {
+ Err(std::env::VarError::NotPresent) => default,
+ Ok(raw) => match raw.parse::() {
+ Ok(value) if value >= min && value <= max => value,
+ _ => {
+ tracing::warn!(
+ event = "auth_config_value_invalid",
+ variable = name,
+ value = raw,
+ min = %min,
+ max = %max,
+ fallback = %default,
+ "invalid authentication configuration value; using the default"
+ );
+ default
+ }
+ },
+ Err(std::env::VarError::NotUnicode(_)) => {
+ tracing::warn!(
+ event = "auth_config_value_invalid",
+ variable = name,
+ min = %min,
+ max = %max,
+ fallback = %default,
+ "authentication configuration value is not UTF-8; using the default"
+ );
+ default
+ }
+ }
+}
diff --git a/crates/pb-mapper-auth/src/ids.rs b/crates/pb-mapper-auth/src/ids.rs
new file mode 100644
index 0000000..fdd5a0e
--- /dev/null
+++ b/crates/pb-mapper-auth/src/ids.rs
@@ -0,0 +1,129 @@
+//! Identity types for temporary credentials.
+//!
+//! ```text
+//! KeyId (u64) — what a client presents
+//! ┌──────────────────────────┬──────────────────────────┐
+//! │ Generation (high 32) │ SlotIndex (low 32) │
+//! └──────────────────────────┴──────────────────────────┘
+//! which tenant of the row which row of the table
+//! ```
+//!
+//! These were all bare integers, which made `make_key_id(generation, slot)`
+//! accept its arguments in either order and let a slot index be compared against
+//! a generation without complaint. Separate types make both a compile error, and
+//! keep a `KeyId` from being used as an array index by mistake — the only way to
+//! get one is [`KeyId::slot`], which is also the only place the truncation to a
+//! row number is expressed.
+//!
+//! All three are `#[serde(transparent)]`, so persisted snapshots and the admin
+//! wire protocol keep the plain-integer encoding they already had.
+
+use std::fmt;
+
+use serde::{Deserialize, Serialize};
+
+/// The identity a client presents: a [`SlotIndex`] paired with the
+/// [`Generation`] of the row it was issued from.
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
+#[serde(transparent)]
+pub struct KeyId(u64);
+
+/// Which tenant of a slot a credential belongs to. Bumped every time the row is
+/// reissued, and never reset, so a retired credential can never match the row
+/// that replaced it.
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
+#[serde(transparent)]
+pub struct Generation(u32);
+
+/// Which row of the slot table a credential lives in.
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
+#[serde(transparent)]
+pub struct SlotIndex(u32);
+
+/// The administrator, which owns no slot and never expires.
+pub const ADMIN_KEY_ID: KeyId = KeyId(0);
+
+impl KeyId {
+ pub const fn new(generation: Generation, slot: SlotIndex) -> Self {
+ Self(((generation.0 as u64) << 32) | slot.0 as u64)
+ }
+
+ pub const fn generation(self) -> Generation {
+ Generation((self.0 >> 32) as u32)
+ }
+
+ pub const fn slot(self) -> SlotIndex {
+ SlotIndex(self.0 as u32)
+ }
+
+ pub const fn is_admin(self) -> bool {
+ self.0 == ADMIN_KEY_ID.0
+ }
+
+ /// The bytes mixed into the credential's key derivation.
+ pub const fn to_be_bytes(self) -> [u8; 8] {
+ self.0.to_be_bytes()
+ }
+
+ pub const fn as_u64(self) -> u64 {
+ self.0
+ }
+
+ pub const fn from_u64(raw: u64) -> Self {
+ Self(raw)
+ }
+}
+
+impl Generation {
+ pub const FIRST: Self = Self(0);
+
+ /// The generation for a reissue of this row, or `None` once the row has been
+ /// cycled `u32::MAX` times and can no longer produce a fresh identity.
+ pub fn next(self) -> Option {
+ self.0.checked_add(1).map(Self)
+ }
+
+ pub const fn as_u32(self) -> u32 {
+ self.0
+ }
+
+ pub const fn from_u32(raw: u32) -> Self {
+ Self(raw)
+ }
+}
+
+impl SlotIndex {
+ pub const fn as_index(self) -> usize {
+ self.0 as usize
+ }
+
+ /// # Panics
+ ///
+ /// If `index` exceeds `u32::MAX`. `MAX_TEMP_KEY_CAPACITY` caps the table far
+ /// below that, so a real index cannot reach it; panicking keeps a future
+ /// capacity change from silently wrapping into another row's identity.
+ pub fn from_index(index: usize) -> Self {
+ match u32::try_from(index) {
+ Ok(index) => Self(index),
+ Err(_) => panic!("slot index exceeds the addressable slot table"),
+ }
+ }
+}
+
+impl fmt::Display for KeyId {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ self.0.fmt(formatter)
+ }
+}
+
+impl fmt::Display for Generation {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ self.0.fmt(formatter)
+ }
+}
+
+impl fmt::Display for SlotIndex {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ self.0.fmt(formatter)
+ }
+}
diff --git a/crates/pb-mapper-auth/src/keys.rs b/crates/pb-mapper-auth/src/keys.rs
new file mode 100644
index 0000000..5df9e99
--- /dev/null
+++ b/crates/pb-mapper-auth/src/keys.rs
@@ -0,0 +1,231 @@
+//! Administrator credential load, recovery, and temporary-key derivation.
+use super::*;
+
+fn read_admin_key(path: &Path) -> Result, AuthFailure> {
+ if !path.exists() {
+ return Ok(None);
+ }
+ #[cfg(unix)]
+ {
+ let metadata = std::fs::metadata(path).map_err(|error| {
+ AuthFailure::new(
+ "administrator_key_required",
+ format!(
+ "administrator key file `{}` metadata could not be read: {error}",
+ path.display()
+ ),
+ false,
+ )
+ })?;
+ if metadata.permissions().mode() & 0o077 != 0 {
+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(
+ |error| {
+ AuthFailure::new(
+ "administrator_key_required",
+ format!(
+ "administrator key file `{}` permissions could not be secured: {error}",
+ path.display()
+ ),
+ false,
+ )
+ },
+ )?;
+ tracing::warn!(
+ event = "administrator_key_permissions_repaired",
+ path = %path.display(),
+ "restricted administrator key file permissions to 0600"
+ );
+ }
+ }
+ std::fs::read_to_string(path).map(Some).map_err(|error| {
+ AuthFailure::new(
+ "administrator_key_required",
+ format!(
+ "administrator key file `{}` could not be read: {error}",
+ path.display()
+ ),
+ false,
+ )
+ })
+}
+
+fn persist_recovery_admin_key(
+ state_dir: &Path,
+ key: &str,
+ mismatch_message: &'static str,
+) -> Result<(), AuthFailure> {
+ if encrypted_auth_state_exists(state_dir) && !key_matches_existing_state(Some(state_dir), key) {
+ return Err(AuthFailure::new(
+ "administrator_key_invalid",
+ mismatch_message,
+ false,
+ ));
+ }
+ write_admin_key(state_dir, key)
+}
+
+fn validate_admin_credential(raw: &str) -> Result {
+ let credential = parse_credential(raw.trim())
+ .map_err(|error| AuthFailure::new("administrator_key_invalid", error, false))?;
+ if !credential.is_admin() {
+ return Err(AuthFailure::new(
+ "administrator_key_required",
+ "the server key file contains a temporary credential",
+ false,
+ ));
+ }
+ Ok(credential)
+}
+
+pub(super) fn recover_admin_key_after_rotation(
+ state_dir: &Path,
+ current: &str,
+) -> Result {
+ let snapshot_path = auth_snapshot_path(state_dir);
+ if !snapshot_path.exists() {
+ return Ok(current.to_string());
+ }
+ let bytes = std::fs::read(&snapshot_path).map_err(|error| {
+ AuthFailure::new(
+ "temporary_key_store_unavailable",
+ format!("failed to read `{}`: {error}", snapshot_path.display()),
+ false,
+ )
+ })?;
+ if let Ok(Credential::Admin(current_key)) = parse_credential(current.trim())
+ && open_blob(¤t_key, &bytes).is_ok()
+ {
+ return Ok(current.to_string());
+ }
+ let Some(next) = read_admin_key(&state_dir.join("admin.key.next"))? else {
+ return Ok(current.to_string());
+ };
+ let Ok(Credential::Admin(next_key)) = parse_credential(next.trim()) else {
+ return Ok(current.to_string());
+ };
+ if open_blob(&next_key, &bytes).is_err() {
+ return Ok(current.to_string());
+ }
+ // The rotation snapshot is complete under the staged key. Leftover WAL
+ // records are still encrypted with the previous key.
+ truncate_auth_wal(state_dir)?;
+ write_admin_key(state_dir, next.trim())?;
+ let _ = std::fs::remove_file(state_dir.join("admin.key.next"));
+ Ok(next)
+}
+
+pub(super) fn load_server_admin_credential(state_dir: &Path) -> Result {
+ let path = state_dir.join("admin.key");
+ let raw = if let Some(raw) = read_admin_key(&path)? {
+ raw
+ } else if std::env::var_os(ENV_MSG_HEADER_KEY).is_some() {
+ let credential = get_process_credential()
+ .map_err(|error| AuthFailure::new("administrator_key_invalid", error, false))?;
+ let Credential::Admin(key) = credential else {
+ return Err(AuthFailure::new(
+ "administrator_key_required",
+ "the relay server cannot start with a temporary credential",
+ false,
+ ));
+ };
+ let key = String::from_utf8(key.to_vec()).map_err(|_| {
+ AuthFailure::new(
+ "administrator_key_invalid",
+ "the relay administrator key must be printable UTF-8 so it can be persisted",
+ false,
+ )
+ })?;
+ persist_recovery_admin_key(
+ state_dir,
+ &key,
+ "MSG_HEADER_KEY does not decrypt the existing authentication state; refusing to write admin.key",
+ )?;
+ key
+ } else if Path::new(MACHINE_MSG_HEADER_KEY_PATH).is_file() {
+ let key = std::fs::read_to_string(MACHINE_MSG_HEADER_KEY_PATH).map_err(|error| {
+ AuthFailure::new(
+ "administrator_key_required",
+ format!(
+ "legacy administrator key file `{MACHINE_MSG_HEADER_KEY_PATH}` could not be read: {error}"
+ ),
+ false,
+ )
+ })?;
+ validate_admin_credential(&key)?;
+ persist_recovery_admin_key(
+ state_dir,
+ key.trim(),
+ "legacy administrator key does not decrypt the existing authentication state; refusing to write admin.key",
+ )?;
+ tracing::warn!(
+ event = "administrator_key_migrated",
+ source = MACHINE_MSG_HEADER_KEY_PATH,
+ destination = %path.display(),
+ "migrated the legacy administrator key into the v0.4 authentication state directory"
+ );
+ key
+ } else {
+ let key = initialize_admin_key(&path, false)?;
+ tracing::warn!(
+ event = "administrator_key_initialized",
+ path = %path.display(),
+ "no administrator credential was configured; generated a random key file"
+ );
+ key
+ };
+ let raw = recover_admin_key_after_rotation(state_dir, &raw)?;
+ let credential = validate_admin_credential(&raw)?;
+ set_process_msg_header_key(Some(raw.trim())).map_err(AuthFailure::internal)?;
+ Ok(credential)
+}
+
+/// Load or create an app-local relay root without reading or mutating the process credential.
+///
+/// The Flutter process uses its configured process credential for the remote relay, while its
+/// optional embedded relay owns an independent administrator key under the app data directory.
+pub(super) fn load_isolated_server_admin_credential(
+ state_dir: &Path,
+) -> Result {
+ let path = state_dir.join("admin.key");
+ let raw = match read_admin_key(&path)? {
+ Some(raw) => raw,
+ None => {
+ let key = initialize_admin_key(&path, false)?;
+ tracing::warn!(
+ event = "isolated_administrator_key_initialized",
+ path = %path.display(),
+ "generated an administrator key for an embedded relay"
+ );
+ key
+ }
+ };
+ let raw = recover_admin_key_after_rotation(state_dir, &raw)?;
+ validate_admin_credential(&raw)
+}
+
+pub fn derive_temporary_key(
+ admin_key: &AesKeyType,
+ instance_id: &[u8; INSTANCE_ID_LEN],
+ key_id: KeyId,
+) -> Result {
+ let salt = Salt::new(HKDF_SHA256, instance_id);
+ let pseudo_random_key = salt.extract(admin_key);
+ let key_id_bytes = key_id.to_be_bytes();
+ let info = [b"pb-mapper-temp-key-v1".as_slice(), key_id_bytes.as_slice()];
+ let output = pseudo_random_key
+ .expand(&info, HkdfLen(32))
+ .map_err(|_| AuthFailure::internal("failed to expand temporary key"))?;
+ let mut key = [0_u8; 32];
+ output
+ .fill(&mut key)
+ .map_err(|_| AuthFailure::internal("failed to fill temporary key"))?;
+ Ok(key)
+}
+
+struct HkdfLen(usize);
+
+impl ring::hkdf::KeyType for HkdfLen {
+ fn len(&self) -> usize {
+ self.0
+ }
+}
diff --git a/crates/pb-mapper-auth/src/leases.rs b/crates/pb-mapper-auth/src/leases.rs
new file mode 100644
index 0000000..8fb741c
--- /dev/null
+++ b/crates/pb-mapper-auth/src/leases.rs
@@ -0,0 +1,356 @@
+//! Temporary-key lifetimes, scheduled on the timing wheel.
+//!
+//! ```text
+//! issue schedule(expires_at) ── slots[i] Active, lease live
+//! |
+//! v deadline arrives, or a revoke fires the timer early
+//! retire lease cancelled, slots[i] Expired, tombstoned_at recorded,
+//! a reap timer scheduled for +TOMBSTONE_RETENTION
+//! | <- a client presenting the dead credential is told
+//! | "expired", not the "unknown key" it would get from
+//! v an already-recycled row
+//! reap slots[i] Free (generation kept), cold metadata and any high-slot
+//! row removed
+//! ```
+//!
+//! Both stages are timers, so nothing sweeps and no queue has to stay in step
+//! with the wheel. Every way a key can end runs the same callback: a deadline
+//! arriving runs it on schedule, [`Timer::fire`] runs it early for a revoke or a
+//! GC, and dropping the wheel runs it for a rotation or shutdown.
+//!
+//! `timers` maps each key to a `Weak` handle on its current timer, which is what
+//! keeps key identity out of the wheel. Renewing upgrades the handle and
+//! schedules the same timer at the later deadline: the earlier placement still
+//! drains, but it is no longer the last reference, so nothing fires. Because the
+//! map holds only `Weak` references, an entry whose timer has fired costs nothing
+//! but a stale key, cleared by the callback itself.
+//!
+//! The callbacks hold a `Weak`, so they neither keep the state
+//! alive nor touch it after a runtime has shut down.
+
+use super::*;
+
+/// A key's two scheduled stages. Both are `Weak`, so a stage that has already
+/// run costs nothing but a stale map key.
+#[derive(Default)]
+struct Stages {
+ retire: Weak,
+ reap: Weak,
+}
+
+pub(super) struct Leases {
+ inner: Weak,
+ wheel: TimingWheel,
+ /// The wall-clock second the wheel's current tick corresponds to. The wheel
+ /// itself only counts ticks, so this is where absolute deadlines are turned
+ /// into the relative delays it takes.
+ now: u64,
+ /// Each key's stages, so a renew or an early end can reach them without the
+ /// wheel knowing what a key is.
+ stages: HashMap,
+}
+
+impl Leases {
+ /// Rebuilds a loaded state's schedule: live keys wait for their expiry, and
+ /// keys that were already dead wait out the rest of their retention.
+ pub(super) fn restored(inner: &Arc, now: u64) -> Self {
+ let mut leases = Self {
+ inner: Arc::downgrade(inner),
+ wheel: new_wheel(),
+ now,
+ stages: HashMap::new(),
+ };
+ let mut live = Vec::new();
+ let mut dead = Vec::new();
+ for (index, slot) in inner.slots().iter().enumerate() {
+ let key_id = KeyId::new(slot.generation, SlotIndex::from_index(index));
+ match slot.state {
+ SlotState::Active => live.extend(slot.lease.upgrade().map(|l| (key_id, l))),
+ SlotState::Expired | SlotState::Revoked => dead.push(key_id),
+ SlotState::Free => {}
+ }
+ }
+ dead.extend(
+ inner
+ .high()
+ .iter()
+ .filter(|entry| entry.state != SlotState::Active)
+ .map(|entry| entry.key_id),
+ );
+ for (key_id, lease) in live {
+ leases.watch(key_id, lease);
+ }
+ for key_id in dead {
+ let tombstoned_at = inner
+ .cold()
+ .get(&key_id)
+ .map(|cold| cold.tombstoned_at)
+ .filter(|at| *at != 0)
+ .unwrap_or(now);
+ leases.schedule_reap(key_id, retention_ends(tombstoned_at));
+ }
+ leases
+ }
+
+ /// Takes over a newly issued key: records its description and schedules both
+ /// stages of its teardown.
+ pub(super) fn issue(&mut self, lease: &Arc, issued_at: u64, label: Option) {
+ let Some(inner) = self.inner.upgrade() else {
+ return;
+ };
+ inner.cold_mut().insert(
+ lease.key_id(),
+ ColdMetadata {
+ issued_at,
+ label,
+ tombstoned_at: 0,
+ },
+ );
+ self.watch(lease.key_id(), lease.clone());
+ }
+
+ /// Hands a key's remaining life to a replacement lease, for a renewal whose
+ /// original lease had already been cancelled.
+ pub(super) fn adopt(&mut self, lease: &Arc) {
+ self.watch(lease.key_id(), lease.clone());
+ }
+
+ /// Moves a renewed key to its new expiry, and its reap along with it. Returns
+ /// `false` for a key with no live stages, as for a high slot.
+ ///
+ /// Each timer is scheduled a second time rather than moved: its earlier
+ /// placement drains on the old deadline but is no longer the last reference,
+ /// so it fires nothing.
+ pub(super) fn renew(&mut self, key_id: KeyId, expires_at: u64) -> bool {
+ let Some(stages) = self.stages.get(&key_id) else {
+ return false;
+ };
+ let (Some(retire), Some(reap)) = (stages.retire.upgrade(), stages.reap.upgrade()) else {
+ return false;
+ };
+ self.schedule_at(expires_at, retire);
+ self.schedule_at(retention_ends(expires_at), reap);
+ true
+ }
+
+ /// Retires a key now rather than at its expiry, leaving its reap on schedule.
+ /// This is what a revoke needs: the credential stops working immediately, but
+ /// the row is held long enough to report *why*.
+ pub(super) fn retire_now(&mut self, key_id: KeyId) {
+ if let Some(retire) = self.stage(key_id, |stages| &stages.retire) {
+ retire.fire();
+ }
+ }
+
+ /// Ends a key outright, running both stages. Skips the retention wait, so it
+ /// is for a caller that wants the row back now.
+ pub(super) fn end(&mut self, key_id: KeyId) {
+ self.retire_now(key_id);
+ if let Some(reap) = self.stage(key_id, |stages| &stages.reap) {
+ reap.fire();
+ }
+ self.stages.remove(&key_id);
+ }
+
+ /// Runs every callback whose deadline has passed.
+ pub(super) fn tick(&mut self, now: u64) {
+ // A jump longer than anything a key can be scheduled for means every
+ // timer is due, so the schedule is dropped wholesale instead of ticked up
+ // to. That keeps a corrected hardware clock from spinning for hours.
+ //
+ // A clock stepping backwards is ignored: buckets are indexed relative to
+ // the wheel's `now`, so re-filing against an earlier one would place
+ // entries in slots it has already drained.
+ if now.saturating_sub(self.now) > self.wheel.max_delay() {
+ self.drop_schedule(now);
+ return;
+ }
+ while self.now < now {
+ self.now += 1;
+ self.wheel.tick();
+ }
+ }
+
+ /// Ends every key at once, for a root rotation or state reset. Dropping the
+ /// wheel releases the last reference to every timer, so no row, lease, or
+ /// metadata entry survives it.
+ pub(super) fn wipe(&mut self, now: u64) {
+ // Rotation is the one reason a callback cannot infer, so it is recorded
+ // before the drop; `record_cancel` keeps the first reason.
+ if let Some(inner) = self.inner.upgrade() {
+ for lease in inner.slots().iter().filter_map(|slot| slot.lease.upgrade()) {
+ lease.cancel_rotated();
+ }
+ }
+ self.drop_schedule(now);
+ }
+
+ /// Ends every key that is dead or past its deadline, skipping the retention
+ /// wait. Returns how many keys were ended.
+ pub(super) fn collect_garbage(&mut self, now: u64) -> u64 {
+ let Some(inner) = self.inner.upgrade() else {
+ return 0;
+ };
+ let mut due = inner
+ .slots()
+ .iter()
+ .enumerate()
+ .filter(|(_, slot)| slot.is_collectable(now))
+ .map(|(index, slot)| KeyId::new(slot.generation, SlotIndex::from_index(index)))
+ .collect::>();
+ due.extend(
+ inner
+ .high()
+ .iter()
+ .filter(|entry| entry.state != SlotState::Active || entry.expires_at <= now)
+ .map(|entry| entry.key_id),
+ );
+ for key_id in &due {
+ self.end(*key_id);
+ }
+ due.len() as u64
+ }
+
+ /// Replaces the whole schedule, running every callback the old one held.
+ fn drop_schedule(&mut self, now: u64) {
+ self.stages.clear();
+ self.now = now;
+ self.wheel = new_wheel();
+ }
+
+ /// Schedules `timer` for an absolute second, as the delay from now the wheel
+ /// works in. A deadline already past releases the timer at once.
+ fn schedule_at(&mut self, deadline: u64, timer: Arc) {
+ self.wheel
+ .schedule(deadline.saturating_sub(self.now), timer);
+ }
+
+ /// Upgrades one of a key's stages, forgetting the key once both have run.
+ fn stage(
+ &mut self,
+ key_id: KeyId,
+ which: impl Fn(&Stages) -> &Weak,
+ ) -> Option> {
+ let stages = self.stages.get(&key_id)?;
+ let timer = which(stages).upgrade();
+ if stages.retire.strong_count() == 0 && stages.reap.strong_count() == 0 {
+ self.stages.remove(&key_id);
+ }
+ timer
+ }
+
+ /// Schedules both stages of a live key: retirement at its lease's expiry, and
+ /// the reap a retention window later.
+ ///
+ /// WHY the reap timer owns the lease rather than the retire timer: the slot
+ /// table holds only a `Weak`, so this is the reference that lets a request
+ /// during the retention window read *why* the key died instead of finding a
+ /// vanished lease. Firing a timer consumes its callback, so an `Arc` held by
+ /// the retire stage would be released the moment that stage ran.
+ fn watch(&mut self, key_id: KeyId, lease: Arc) {
+ let inner = self.inner.clone();
+ let expires_at = lease.expires_at();
+ let retire_lease = lease.clone();
+ let retire = Timer::new(move || {
+ if let Some(inner) = inner.upgrade() {
+ retire(&inner, key_id, &retire_lease);
+ }
+ });
+ let reap = self.reap_timer(key_id, Some(lease));
+ self.stages.insert(
+ key_id,
+ Stages {
+ retire: Arc::downgrade(&retire),
+ reap: Arc::downgrade(&reap),
+ },
+ );
+ self.schedule_at(expires_at, retire);
+ self.schedule_at(retention_ends(expires_at), reap);
+ }
+
+ /// Schedules only the reap, for a key that is already dead.
+ fn schedule_reap(&mut self, key_id: KeyId, deadline: u64) {
+ let reap = self.reap_timer(key_id, None);
+ self.stages.insert(
+ key_id,
+ Stages {
+ reap: Arc::downgrade(&reap),
+ ..Stages::default()
+ },
+ );
+ self.schedule_at(deadline, reap);
+ }
+
+ /// Builds the reap stage. `lease` is the key's live lease when there is one,
+ /// kept alive by this timer until the row is recycled.
+ fn reap_timer(&self, key_id: KeyId, lease: Option>) -> Arc {
+ let inner = self.inner.clone();
+ Timer::new(move || {
+ drop(lease);
+ if let Some(inner) = inner.upgrade() {
+ reap(&inner, key_id);
+ }
+ })
+ }
+}
+
+fn retention_ends(tombstoned_at: u64) -> u64 {
+ tombstoned_at.saturating_add(TOMBSTONE_RETENTION.as_secs())
+}
+
+/// Ends a key's active stage: cancels the lease, marks the row dead, records when
+/// its retention starts, and schedules the reap that frees the row.
+fn retire(inner: &Arc, key_id: KeyId, lease: &Arc) {
+ // WHY expiry is the fallback reason: a key ended for any other reason was
+ // already cancelled by the code that knew that reason, and `record_cancel`
+ // keeps the first one, so this cannot mislabel it.
+ lease.cancel_expired();
+ let mut slots = inner.slots_mut();
+ let Some(slot) = slots.get_mut(key_id.slot().as_index()) else {
+ return;
+ };
+ // Already dead, or the row moved on: a revoke marked it and recorded its
+ // tombstone time, and the reap is already scheduled either way.
+ if !slot.holds(key_id) || slot.state != SlotState::Active {
+ return;
+ }
+ slot.state = SlotState::Expired;
+ let tombstoned_at = slot.expires_at;
+ drop(slots);
+ inner
+ .cold_mut()
+ .entry(key_id)
+ .and_modify(|cold| cold.tombstoned_at = tombstoned_at);
+ tracing::info!(
+ event = "temporary_key_expired",
+ auth_stage = "expiry",
+ key_id = key_id.as_u64(),
+ expires_at = tombstoned_at,
+ "temporary key expired and active work was cancelled"
+ );
+}
+
+/// Frees a dead key's row and forgets it.
+fn reap(inner: &Arc, key_id: KeyId) {
+ let mut slots = inner.slots_mut();
+ match slots.get_mut(key_id.slot().as_index()) {
+ Some(slot) if slot.holds(key_id) => {
+ slot.retire();
+ drop(slots);
+ }
+ Some(_) => return,
+ // Above the addressable table: the retained row is dropped outright,
+ // since only its generation has to survive.
+ None => {
+ drop(slots);
+ inner.high_mut().retain(|entry| entry.key_id != key_id);
+ }
+ }
+ inner.cold_mut().remove(&key_id);
+}
+
+/// The wheel every schedule uses: wide enough for the longest lifetime a key can
+/// have, at 64 buckets per level.
+fn new_wheel() -> TimingWheel {
+ TimingWheel::new(MAX_SCHEDULABLE_DELAY.as_secs(), 64)
+}
diff --git a/crates/pb-mapper-auth/src/lib.rs b/crates/pb-mapper-auth/src/lib.rs
new file mode 100644
index 0000000..ec1772b
--- /dev/null
+++ b/crates/pb-mapper-auth/src/lib.rs
@@ -0,0 +1,784 @@
+//! Authentication state for protocol-v2 connections and administrator operations.
+//!
+//! # How a temporary credential works
+//!
+//! Nothing secret is stored per key. A temporary credential is *derived* from the
+//! root key, the server instance id, and the key id, so the server can verify a
+//! credential it holds no copy of, and a key id is all the state a key needs:
+//!
+//! ```text
+//! issue: root key + instance id + key id --HKDF--> credential handed to the client
+//! verify: root key + instance id + key id --HKDF--> compare against what was presented
+//! ```
+//!
+//! Because the material is derived, invalidating every key at once is a matter of
+//! changing an input: a root rotation replaces the root key, a state reset replaces
+//! the instance id. Neither has to touch individual keys.
+//!
+//! # Where the state lives
+//!
+//! ```text
+//! key_id = generation:slot
+//! |
+//! request ──> derive & compare ──> slots[slot] ── lifecycle: Free/Active/
+//! │ Expired/Revoked, expires_at
+//! │
+//! Weak lease ──> Arc lease, owned by the actor's
+//! ^ timing wheel — the single place
+//! │ a lease's lifetime ends
+//! AuthContext (also Weak) ─────────────┘
+//! ```
+//!
+//! The slot table is a preallocated array indexed straight off the key id, so
+//! verification costs an array index and churn does not grow memory. The
+//! `SlotState` docs below cover the table's layout, why generations exist, and
+//! why dead rows linger. `Leases` (`leases.rs`) owns the three structures a
+//! key's lifetime spans; `timing_wheel.rs` schedules the expiries.
+//!
+//! # Where mutations happen
+//!
+//! ```text
+//! AuthRuntime (facade) ──channel──> one actor ──> encrypted snapshot + WAL
+//! ```
+//!
+//! Every mutation is serialized through a single actor, so a request authorized
+//! before a root rotation cannot execute against the state that replaced it.
+//!
+//! The facade and model types stay in this root module; runtime checks, actor
+//! mutations, persistence, expiry scheduling, and tests live in the children.
+
+use std::collections::{HashMap, HashSet, VecDeque};
+use std::fmt;
+use std::fs::{File, OpenOptions};
+use std::io::{Read, Write};
+#[cfg(unix)]
+use std::os::unix::fs::PermissionsExt;
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
+use std::sync::{Arc, Weak};
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+use parking_lot::{Mutex, RwLock};
+use rand::RngExt;
+use ring::aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey};
+use ring::hkdf::{HKDF_SHA256, Salt};
+use serde::{Deserialize, Serialize};
+use subtle::ConstantTimeEq;
+use tokio::sync::{mpsc, oneshot};
+use tokio_util::sync::CancellationToken;
+
+use pb_mapper_core::checksum::{
+ AesKeyType, Credential, ENV_MSG_HEADER_KEY, MACHINE_MSG_HEADER_KEY_PATH,
+ encode_temporary_credential, env_safe_admin_key_error, get_process_credential,
+ is_env_safe_admin_key, parse_credential, set_process_msg_header_key,
+};
+
+/// The namespace administrator connections operate in. Tenant namespaces are the
+/// key id that owns them, so this mirrors [`ADMIN_KEY_ID`].
+pub const ADMIN_NAMESPACE: u64 = ADMIN_KEY_ID.as_u64();
+pub const DEFAULT_AUTH_STATE_DIR: &str = "/var/lib/pb-mapper/auth";
+pub const DEFAULT_TEMP_KEY_CAPACITY: usize = 65_536;
+pub const MAX_TEMP_KEY_CAPACITY: usize = 1_048_576;
+pub const DEFAULT_MAX_TEMP_KEY_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
+pub const MIN_TEMP_KEY_TTL: Duration = Duration::from_secs(10);
+pub const MAX_TEMP_KEY_TTL: Duration = Duration::from_secs(365 * 24 * 60 * 60);
+const TOMBSTONE_RETENTION: Duration = Duration::from_secs(60);
+/// Longest delay any scheduled cleanup can ask for, so the timing wheel can tell
+/// a plausible wait from a clock correction.
+const MAX_SCHEDULABLE_DELAY: Duration =
+ Duration::from_secs(MAX_TEMP_KEY_TTL.as_secs() + TOMBSTONE_RETENTION.as_secs());
+const SNAPSHOT_COMPACTION_INTERVAL: Duration = Duration::from_secs(5 * 60);
+const SNAPSHOT_SCHEMA_VERSION: u16 = 1;
+const STATE_BLOB_MAGIC: &[u8; 5] = b"PBAS1";
+const STATE_AAD: &[u8] = b"pb-mapper-auth-state-v1";
+const INSTANCE_ID_LEN: usize = 16;
+const ADMIN_REPLAY_RETENTION: Duration = Duration::from_secs(10 * 60);
+const ADMIN_REPLAY_CAPACITY: usize = 65_536;
+const AUDIT_RECORD_CAPACITY: usize = 4096;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum LegacyProtocolPolicy {
+ Allow,
+ Deny,
+}
+
+impl LegacyProtocolPolicy {
+ pub fn is_allowed(self) -> bool {
+ matches!(self, Self::Allow)
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct AuthConfig {
+ pub state_dir: PathBuf,
+ pub max_temporary_keys: usize,
+ pub max_temporary_key_ttl: Duration,
+ pub legacy_protocol: LegacyProtocolPolicy,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct AuthFailure {
+ pub code: String,
+ pub message: String,
+ pub retryable: bool,
+}
+
+impl AuthFailure {
+ pub fn new(code: impl Into, message: impl Into, retryable: bool) -> Self {
+ Self {
+ code: code.into(),
+ message: message.into(),
+ retryable,
+ }
+ }
+
+ pub fn internal(message: impl Into) -> Self {
+ Self::new("auth_internal_error", message, false)
+ }
+}
+
+impl fmt::Display for AuthFailure {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(formatter, "{}: {}", self.code, self.message)
+ }
+}
+
+impl std::error::Error for AuthFailure {}
+
+const LEASE_CANCEL_NONE: u8 = 0;
+const LEASE_CANCEL_EXPIRED: u8 = 1;
+const LEASE_CANCEL_REVOKED: u8 = 2;
+const LEASE_CANCEL_ROTATED: u8 = 3;
+
+#[derive(Debug)]
+pub struct AuthLease {
+ key_id: KeyId,
+ expires_at: AtomicU64,
+ cancellation: CancellationToken,
+ cancel_reason: AtomicU8,
+}
+
+impl AuthLease {
+ fn new(key_id: KeyId, expires_at: u64) -> Self {
+ Self {
+ key_id,
+ expires_at: AtomicU64::new(expires_at),
+ cancellation: CancellationToken::new(),
+ cancel_reason: AtomicU8::new(LEASE_CANCEL_NONE),
+ }
+ }
+
+ pub fn key_id(&self) -> KeyId {
+ self.key_id
+ }
+
+ pub fn expires_at(&self) -> u64 {
+ self.expires_at.load(Ordering::Acquire)
+ }
+
+ pub fn cancellation_token(&self) -> CancellationToken {
+ self.cancellation.clone()
+ }
+
+ fn record_cancel(&self, reason: u8) {
+ let _ = self.cancel_reason.compare_exchange(
+ LEASE_CANCEL_NONE,
+ reason,
+ Ordering::AcqRel,
+ Ordering::Acquire,
+ );
+ self.cancellation.cancel();
+ }
+
+ pub(crate) fn cancel_expired(&self) {
+ self.record_cancel(LEASE_CANCEL_EXPIRED);
+ }
+
+ pub(crate) fn cancel_revoked(&self) {
+ self.record_cancel(LEASE_CANCEL_REVOKED);
+ }
+
+ pub(crate) fn cancel_rotated(&self) {
+ self.record_cancel(LEASE_CANCEL_ROTATED);
+ }
+
+ #[cfg(test)]
+ pub(crate) fn expire_now(&self) {
+ self.expires_at.store(0, Ordering::Release);
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct AuthContext {
+ pub key_id: KeyId,
+ pub namespace: u64,
+ pub is_admin: bool,
+ lease: Weak,
+}
+
+impl AuthContext {
+ fn from_lease(key_id: KeyId, is_admin: bool, lease: &Arc) -> Self {
+ Self {
+ key_id,
+ namespace: if is_admin {
+ ADMIN_NAMESPACE
+ } else {
+ key_id.as_u64()
+ },
+ is_admin,
+ lease: Arc::downgrade(lease),
+ }
+ }
+
+ pub fn ensure_active(&self) -> Result, AuthFailure> {
+ let lease = self.lease.upgrade().ok_or_else(|| {
+ AuthFailure::new(
+ if self.is_admin {
+ "administrator_key_rotated"
+ } else {
+ "temporary_key_inactive"
+ },
+ "credential lease is no longer active",
+ false,
+ )
+ })?;
+ if lease.cancellation.is_cancelled() {
+ return Err(cancelled_lease_failure(self.is_admin, &lease));
+ }
+ if !self.is_admin && lease.expires_at() <= unix_seconds() {
+ lease.cancel_expired();
+ return Err(AuthFailure::new(
+ "temporary_key_expired",
+ "temporary key has expired",
+ false,
+ ));
+ }
+ Ok(lease)
+ }
+
+ pub fn cancellation_token(&self) -> Result {
+ Ok(self.ensure_active()?.cancellation_token())
+ }
+
+ pub fn admin_cancellation_token(&self) -> Result {
+ self.require_admin()?;
+ self.cancellation_token()
+ }
+
+ fn admin_authority(&self) -> Result, AuthFailure> {
+ self.require_admin()?;
+ self.ensure_active()?;
+ Ok(self.lease.clone())
+ }
+
+ fn require_admin(&self) -> Result<(), AuthFailure> {
+ if self.is_admin {
+ Ok(())
+ } else {
+ Err(AuthFailure::new(
+ "admin_permission_required",
+ "administrator credential is required for this operation",
+ false,
+ ))
+ }
+ }
+}
+
+fn cancelled_lease_failure(is_admin: bool, lease: &AuthLease) -> AuthFailure {
+ if is_admin {
+ return AuthFailure::new(
+ "administrator_key_rotated",
+ "credential lease has been cancelled",
+ false,
+ );
+ }
+ match lease.cancel_reason.load(Ordering::Acquire) {
+ LEASE_CANCEL_EXPIRED => {
+ AuthFailure::new("temporary_key_expired", "temporary key has expired", false)
+ }
+ LEASE_CANCEL_ROTATED => AuthFailure::new(
+ "temporary_key_rotated",
+ "temporary credential was invalidated by administrator root rotation or auth-state reset",
+ false,
+ ),
+ LEASE_CANCEL_REVOKED => {
+ AuthFailure::new("temporary_key_revoked", "temporary key was revoked", false)
+ }
+ _ => AuthFailure::new(
+ "temporary_key_inactive",
+ "credential lease has been cancelled",
+ false,
+ ),
+ }
+}
+
+/// # The slot table
+///
+/// A temporary key is never stored. It is *derived* on demand from
+/// `(root key, instance id, key id)`, so the server can verify a credential it
+/// has no copy of. That makes the key id the whole identity of a key, and a
+/// key id is a slot index plus a generation counter:
+///
+/// ```text
+/// key_id: u64
+/// ┌───────────────────────────┬───────────────────────────┐
+/// │ generation (high 32) │ slot index (low 32) │
+/// └───────────────────────────┴───────────────────────────┘
+/// ^ bumped on reuse ^ where the row lives
+/// ```
+///
+/// The slot index is a direct offset into `AuthStateInner::slots`, a
+/// preallocated `Box<[SlotHot]>`. So verifying a credential is an array index,
+/// not a map lookup or a scan, and the table's memory does not grow with churn:
+///
+/// ```text
+/// slots: [ SlotHot; max_temporary_keys ]
+/// idx 0 gen 7 Active expires_at=… lease─┐
+/// idx 1 gen 0 Free │ Weak, so the actor's
+/// idx 2 gen 3 Expired (tombstoned) │ timing wheel is the
+/// idx 3 gen 9 Active expires_at=… lease─┴─ only strong owner
+/// ```
+///
+/// ## Why the generation counter
+///
+/// A freed slot is reused, so the index alone would let a *retired* credential
+/// authenticate against the *new* tenant of that row. The generation bump makes
+/// the old key id refer to a row that no longer exists:
+///
+/// ```text
+/// issue -> idx 2, gen 3 => key_id 0x0000_0003_0000_0002
+/// expire -> idx 2 retired, generation kept at 3
+/// reissue -> idx 2, gen 4 => key_id 0x0000_0004_0000_0002
+/// the old key id still names gen 3, which nothing matches
+/// ```
+///
+/// This is why [`SlotHot::retire`] clears the row but preserves `generation`,
+/// and why a generation is never reset — not by expiry, GC, root rotation, or a
+/// full state reset.
+///
+/// ## The lifecycle
+///
+/// ```text
+/// issue deadline passes / revoke
+/// Free ─────────> Active ──────────────────────────────> Expired
+/// ^ │ Revoked
+/// │ └── renew: same row, later expires_at │
+/// │ │
+/// └──────────── retire, after TOMBSTONE_RETENTION ─────────┘
+/// ```
+///
+/// `Expired`/`Revoked` are tombstones, not garbage. A row lingers in that state
+/// for `TOMBSTONE_RETENTION` so a client that presents a dead credential is told
+/// *why* ("expired", "revoked") instead of receiving the indistinguishable
+/// "unknown key" it would get from an already-recycled row. `Leases` owns that
+/// delay; see `leases.rs`.
+///
+/// ## Slots above capacity
+///
+/// `max_temporary_keys` is configurable, so a restart can shrink the table below
+/// what the persisted state used. Those rows cannot be indexed any more, but
+/// their generations still have to be honoured — otherwise growing the table
+/// again would reissue a key id that was already handed out. They are retained
+/// out-of-line in `high_slot_generations` / `high_slot_entries`, which is why so
+/// many operations check the array first and fall back to a scan of that vector.
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+enum SlotState {
+ /// Never used, or retired and past its tombstone.
+ Free,
+ /// A live credential. `expires_at` is authoritative.
+ Active,
+ /// Dead. Retained for `TOMBSTONE_RETENTION` so the reason survives.
+ Expired,
+ Revoked,
+}
+
+/// One row of the slot table. `generation` outlives every other field.
+#[derive(Debug)]
+struct SlotHot {
+ generation: Generation,
+ state: SlotState,
+ expires_at: u64,
+ /// `Weak`, because the actor's timing wheel holds the strong reference and is
+ /// the single place a lease's lifetime ends. See `timing_wheel.rs`.
+ lease: Weak,
+}
+
+impl SlotHot {
+ /// Whether this row still belongs to `key_id`'s generation. A row that has
+ /// been reissued belongs to a newer tenant and must not be touched on the
+ /// old one's behalf.
+ fn holds(&self, key_id: KeyId) -> bool {
+ self.generation == key_id.generation() && self.state != SlotState::Free
+ }
+
+ /// Whether a garbage collection should free this row: it is already dead, or
+ /// it is active but past its deadline.
+ fn is_collectable(&self, now: u64) -> bool {
+ match self.state {
+ SlotState::Expired | SlotState::Revoked => true,
+ SlotState::Active => self.expires_at <= now,
+ SlotState::Free => false,
+ }
+ }
+
+ /// Frees the slot for reuse while keeping its generation, so a key id that
+ /// has been handed out is never issued a second time.
+ fn retire(&mut self) {
+ *self = Self {
+ generation: self.generation,
+ ..Self::default()
+ };
+ }
+}
+
+impl Default for SlotHot {
+ fn default() -> Self {
+ Self {
+ generation: Generation::FIRST,
+ state: SlotState::Free,
+ expires_at: 0,
+ lease: Weak::new(),
+ }
+ }
+}
+
+#[derive(Debug)]
+struct AdminState {
+ key: AesKeyType,
+ lease: Weak,
+}
+
+#[derive(Clone, Debug)]
+struct PreviousRoot {
+ admin_key: AesKeyType,
+ instance_id: [u8; INSTANCE_ID_LEN],
+}
+
+#[derive(Debug)]
+struct AuthStateInner {
+ admin: RwLock,
+ sync_process_credential: bool,
+ instance_id: RwLock<[u8; INSTANCE_ID_LEN]>,
+ /// Preallocated, indexed directly by `key_slot(key_id)`. Documented on
+ /// [`SlotState`].
+ slots: RwLock>,
+ /// Rows the configured capacity no longer covers, because a restart shrank
+ /// the table below what the persisted state used:
+ ///
+ /// ```text
+ /// slots: [ 0 1 2 3 ] <- indexable
+ /// high: [ 4 5 ] <- generations still honoured, out-of-line
+ /// ```
+ ///
+ /// Their generations must be kept so growing the table again cannot reissue
+ /// a key id that was already handed out, and their entries so a still-live
+ /// credential in that range keeps working. This is the fallback path that
+ /// operations take after missing in `slots`.
+ high_slot_generations: RwLock>,
+ high_slot_entries: RwLock>,
+ /// Per-key description that no authentication check needs, kept out of the
+ /// hot slot row. Lives here rather than inside the actor so a key's handle
+ /// can drop it without the actor being involved; see `leases.rs`.
+ cold: RwLock>,
+ safe_mode: AtomicBool,
+ legacy_protocol_allowed: AtomicBool,
+ active_legacy_connections: AtomicU64,
+ last_legacy_connection_at: AtomicU64,
+ auth_successes: AtomicU64,
+ auth_failures: AtomicU64,
+ root_epoch: AtomicU64,
+ previous_root: RwLock>,
+ audit_records: RwLock>,
+}
+
+impl AuthStateInner {
+ /// Rows the configured capacity no longer covers. See the field's docs; the
+ /// fallback is a scan because the range is small and rarely touched.
+ fn high(&self) -> parking_lot::RwLockReadGuard<'_, Vec> {
+ self.high_slot_entries.read()
+ }
+
+ fn high_mut(&self) -> parking_lot::RwLockWriteGuard<'_, Vec> {
+ self.high_slot_entries.write()
+ }
+
+ fn slots(&self) -> parking_lot::RwLockReadGuard<'_, Box<[SlotHot]>> {
+ self.slots.read()
+ }
+
+ fn slots_mut(&self) -> parking_lot::RwLockWriteGuard<'_, Box<[SlotHot]>> {
+ self.slots.write()
+ }
+
+ fn cold(&self) -> parking_lot::RwLockReadGuard<'_, HashMap> {
+ self.cold.read()
+ }
+
+ fn cold_mut(&self) -> parking_lot::RwLockWriteGuard<'_, HashMap> {
+ self.cold.write()
+ }
+
+ fn admin_key(&self) -> AesKeyType {
+ self.admin.read().key
+ }
+
+ fn instance_id(&self) -> [u8; INSTANCE_ID_LEN] {
+ *self.instance_id.read()
+ }
+}
+
+#[derive(Clone)]
+pub struct AuthRuntime {
+ inner: Weak,
+ command_tx: mpsc::Sender,
+ config: AuthConfig,
+ _state_lock: Arc,
+ actor: Arc>>>,
+ actor_abort: tokio::task::AbortHandle,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct TemporaryKeyMetadata {
+ pub key_id: KeyId,
+ pub state: String,
+ pub issued_at: u64,
+ pub expires_at: u64,
+ pub label: Option,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct IssuedTemporaryKey {
+ #[serde(flatten)]
+ pub metadata: TemporaryKeyMetadata,
+ pub credential: String,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct KeyPage {
+ pub schema_version: u16,
+ pub items: Vec,
+ pub next_page: Option,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct AuthStatus {
+ pub schema_version: u16,
+ pub safe_mode: bool,
+ pub capacity: usize,
+ pub active_keys: usize,
+ pub expired_keys: usize,
+ pub revoked_keys: usize,
+ pub legacy_protocol: LegacyProtocolPolicy,
+ pub active_legacy_connections: u64,
+ pub last_legacy_connection_at: Option,
+ pub auth_successes: u64,
+ pub auth_failures: u64,
+ pub server_instance_id: String,
+}
+
+#[derive(Clone, Debug)]
+struct ColdMetadata {
+ issued_at: u64,
+ label: Option,
+ tombstoned_at: u64,
+}
+
+enum AuthCommand {
+ ClaimAdminMutation {
+ authority: Weak,
+ fingerprint: [u8; 32],
+ client_timestamp: u64,
+ response: oneshot::Sender>,
+ },
+ Issue {
+ authority: Weak,
+ ttl: Duration,
+ label: Option,
+ response: oneshot::Sender>,
+ },
+ List {
+ authority: Weak,
+ page: u32,
+ page_size: u16,
+ response: oneshot::Sender>,
+ },
+ Show {
+ authority: Weak,
+ key_id: KeyId,
+ reveal: bool,
+ response: oneshot::Sender>,
+ },
+ Renew {
+ authority: Weak,
+ key_id: KeyId,
+ ttl: Duration,
+ response: oneshot::Sender>,
+ },
+ Revoke {
+ authority: Weak,
+ key_id: KeyId,
+ response: oneshot::Sender>,
+ },
+ Gc {
+ authority: Weak,
+ response: oneshot::Sender>,
+ },
+ Reset {
+ authority: Weak,
+ response: oneshot::Sender>,
+ },
+ RotateRoot {
+ authority: Weak,
+ new_key: AesKeyType,
+ response: oneshot::Sender>,
+ },
+ SetLegacyProtocol {
+ authority: Weak,
+ policy: LegacyProtocolPolicy,
+ response: oneshot::Sender>,
+ },
+ Status {
+ authority: Weak,
+ response: oneshot::Sender>,
+ },
+ Audit {
+ authority: Weak,
+ action: String,
+ key_id: Option,
+ detail: Option,
+ response: oneshot::Sender>,
+ },
+ Shutdown {
+ response: oneshot::Sender<()>,
+ },
+}
+
+mod config;
+pub use config::default_auth_state_dir;
+#[cfg(all(test, not(any(windows, target_os = "macos"))))]
+pub(crate) use config::linux_default_auth_state_dir;
+#[cfg(test)]
+pub(crate) use config::parse_legacy_protocol_policy;
+#[cfg(test)]
+pub(crate) use config::platform_default_auth_state_dir;
+#[cfg(all(test, not(any(windows, target_os = "macos"))))]
+pub(crate) use config::{linux_system_auth_dir_usable, unix_effective_uid};
+mod keys;
+pub use keys::derive_temporary_key;
+#[cfg(test)]
+pub(crate) use keys::recover_admin_key_after_rotation;
+pub(crate) use keys::{load_isolated_server_admin_credential, load_server_admin_credential};
+mod runtime;
+
+pub struct LegacyConnectionGuard {
+ inner: Weak,
+}
+
+impl Drop for LegacyConnectionGuard {
+ fn drop(&mut self) {
+ if let Some(inner) = self.inner.upgrade() {
+ inner
+ .active_legacy_connections
+ .fetch_sub(1, Ordering::AcqRel);
+ }
+ }
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+struct PersistedEntry {
+ key_id: KeyId,
+ state: SlotState,
+ issued_at: u64,
+ expires_at: u64,
+ label: Option,
+ #[serde(default)]
+ tombstoned_at: Option,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+struct PersistedSnapshot {
+ schema_version: u16,
+ instance_id: [u8; INSTANCE_ID_LEN],
+ generations: Vec,
+ entries: Vec,
+ legacy_protocol: LegacyProtocolPolicy,
+ #[serde(default)]
+ admin_replays: Vec,
+ #[serde(default)]
+ audit_records: VecDeque,
+ #[serde(default)]
+ root_epoch: u64,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+struct AdminReplayRecord {
+ fingerprint: [u8; 32],
+ client_timestamp: u64,
+ /// Server receipt time used for retention. Older snapshots omit this field
+ /// (`0` after serde default) and fall back to `client_timestamp`.
+ #[serde(default)]
+ accepted_at: u64,
+}
+
+impl AdminReplayRecord {
+ fn within_retention(&self, now: u64) -> bool {
+ let anchor = if self.accepted_at == 0 {
+ self.client_timestamp
+ } else {
+ self.accepted_at
+ };
+ now.saturating_sub(anchor) <= ADMIN_REPLAY_RETENTION.as_secs()
+ }
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+enum StateMutation {
+ Issue(PersistedEntry),
+ Renew { key_id: KeyId, expires_at: u64 },
+ Revoke { key_id: KeyId, at: u64 },
+ LegacyProtocol(LegacyProtocolPolicy),
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+struct AuditRecord {
+ at: u64,
+ action: String,
+ key_id: Option,
+ label: Option,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+enum WalRecord {
+ Mutation {
+ mutation: StateMutation,
+ audit: AuditRecord,
+ },
+ Audit(AuditRecord),
+ AdminReplay(AdminReplayRecord),
+}
+
+mod actor;
+use actor::{AuthActorState, run_auth_actor};
+mod persistence;
+pub use persistence::*;
+pub(crate) use persistence::{
+ append_audit, append_mutation, append_wal, atomic_write, auth_snapshot_path, build_snapshot,
+ cancel_all_temporary_leases, compaction_is_allowed, empty_snapshot,
+ fail_closed_on_uncertain_wal, hex, key_matches_existing_state, load_or_create_instance_id,
+ load_persisted_state, normalize_tombstone_times, open_blob, prepare_state_dir_and_lock,
+ push_audit_record, push_persisted_audit, random_instance_id, recover_instance_id_after_reset,
+ reset_already_installed, rotation_already_installed, split_high_slot_state, truncate_auth_wal,
+ unix_seconds, write_admin_key, write_snapshot_and_truncate_wal,
+};
+#[cfg(test)]
+pub(crate) use persistence::{prepare_state_dir, read_instance_id_file, try_load_persisted_state};
+mod ids;
+pub use ids::{ADMIN_KEY_ID, Generation, KeyId, SlotIndex};
+mod leases;
+use leases::Leases;
+mod timing_wheel;
+use timing_wheel::{Timer, TimingWheel};
+#[cfg(test)]
+mod tests;
diff --git a/crates/pb-mapper-auth/src/persistence/admin_key.rs b/crates/pb-mapper-auth/src/persistence/admin_key.rs
new file mode 100644
index 0000000..f523e42
--- /dev/null
+++ b/crates/pb-mapper-auth/src/persistence/admin_key.rs
@@ -0,0 +1,275 @@
+//! Administrator key files, instance id, and recovery-key identity checks.
+use super::super::*;
+use super::{
+ atomic_write, auth_snapshot_path, auth_wal_path, encrypted_auth_state_exists, open_blob,
+ truncate_auth_wal,
+};
+
+pub(crate) fn load_or_create_instance_id(
+ path: &Path,
+) -> Result<[u8; INSTANCE_ID_LEN], AuthFailure> {
+ let instance_path = path.join("server-instance-id");
+ if let Some(instance_id) = read_instance_id_file(&instance_path)? {
+ return Ok(instance_id);
+ }
+ let instance_id = random_instance_id();
+ atomic_write(&instance_path, &instance_id, 0o600)?;
+ Ok(instance_id)
+}
+
+pub(crate) fn read_instance_id_file(
+ path: &Path,
+) -> Result, AuthFailure> {
+ if !path.exists() {
+ return Ok(None);
+ }
+ let bytes = std::fs::read(path).map_err(|error| {
+ AuthFailure::new(
+ "auth_state_unavailable",
+ format!("failed to read `{}`: {error}", path.display()),
+ false,
+ )
+ })?;
+ bytes.try_into().map(Some).map_err(|_| {
+ AuthFailure::new(
+ "auth_state_unavailable",
+ "server instance id must be exactly 16 bytes",
+ false,
+ )
+ })
+}
+
+/// Promote `server-instance-id.next` when the snapshot already belongs to it.
+///
+/// Reset writes that staged file, then the empty snapshot, then the live
+/// instance-id file. A crash after the snapshot lands would otherwise fail
+/// closed on the next start because the live file still has the old id.
+pub(crate) fn recover_instance_id_after_reset(
+ state_dir: &Path,
+ admin_key: &AesKeyType,
+ current: [u8; INSTANCE_ID_LEN],
+) -> Result<[u8; INSTANCE_ID_LEN], AuthFailure> {
+ let next_path = state_dir.join("server-instance-id.next");
+ let Some(next) = read_instance_id_file(&next_path)? else {
+ return Ok(current);
+ };
+ let snapshot_path = auth_snapshot_path(state_dir);
+ if !snapshot_path.exists() {
+ let _ = std::fs::remove_file(&next_path);
+ return Ok(current);
+ }
+ let bytes = std::fs::read(&snapshot_path).map_err(|error| {
+ AuthFailure::new(
+ "temporary_key_store_unavailable",
+ format!("failed to read `{}`: {error}", snapshot_path.display()),
+ false,
+ )
+ })?;
+ let Ok(plain) = open_blob(admin_key, &bytes) else {
+ return Ok(current);
+ };
+ let Ok(snapshot) = serde_json::from_slice::(&plain) else {
+ return Ok(current);
+ };
+ if snapshot.instance_id == current {
+ let _ = std::fs::remove_file(&next_path);
+ return Ok(current);
+ }
+ if snapshot.instance_id != next {
+ return Ok(current);
+ }
+ // The reset snapshot is complete. Any leftover WAL still belongs to the
+ // previous instance and must not be replayed onto the new derivation id.
+ truncate_auth_wal(state_dir)?;
+ atomic_write(&state_dir.join("server-instance-id"), &next, 0o600)?;
+ let _ = std::fs::remove_file(&next_path);
+ Ok(next)
+}
+
+pub(crate) fn random_instance_id() -> [u8; INSTANCE_ID_LEN] {
+ let mut instance_id = [0_u8; INSTANCE_ID_LEN];
+ let mut rng = rand::rng();
+ for byte in &mut instance_id {
+ *byte = rng.random();
+ }
+ instance_id
+}
+
+pub(crate) fn write_admin_key(state_dir: &Path, key: &str) -> Result<(), AuthFailure> {
+ atomic_write(
+ &state_dir.join("admin.key"),
+ format!("{key}\n").as_bytes(),
+ 0o600,
+ )
+}
+
+pub fn generate_admin_key() -> String {
+ const CHARSET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ let mut rng = rand::rng();
+ (0..32)
+ .map(|_| CHARSET[rng.random_range(0..CHARSET.len())] as char)
+ .collect()
+}
+
+pub fn initialize_admin_key(path: &Path, force: bool) -> Result {
+ if path.exists() && !force {
+ return Err(AuthFailure::new(
+ "administrator_key_exists",
+ format!("administrator key file `{}` already exists", path.display()),
+ false,
+ ));
+ }
+ refuse_write_if_encrypted_state(path, force)?;
+ let key = generate_admin_key();
+ atomic_write(path, format!("{key}\n").as_bytes(), 0o600)?;
+ Ok(key)
+}
+
+pub fn write_admin_key_file(path: &Path, key: &str, force: bool) -> Result<(), AuthFailure> {
+ let Credential::Admin(_) = parse_credential(key)
+ .map_err(|error| AuthFailure::new("administrator_key_invalid", error, false))?
+ else {
+ return Err(AuthFailure::new(
+ "administrator_key_invalid",
+ "administrator key file requires a 32-byte administrator key",
+ false,
+ ));
+ };
+ if path.exists() && !force {
+ return Err(AuthFailure::new(
+ "administrator_key_exists",
+ format!(
+ "administrator key file `{}` already exists; pass --force to replace it",
+ path.display()
+ ),
+ false,
+ ));
+ }
+ if path.file_name() == Some(std::ffi::OsStr::new("admin.key"))
+ && !key_matches_existing_state(path.parent(), key)
+ {
+ refuse_write_if_encrypted_state(path, force)?;
+ }
+ atomic_write(path, format!("{key}\n").as_bytes(), 0o600)
+}
+
+pub(crate) fn reset_already_installed(
+ state_dir: &Path,
+ admin_key: &AesKeyType,
+ new_instance_id: &[u8; INSTANCE_ID_LEN],
+) -> bool {
+ let Ok(Some(live)) = read_instance_id_file(&state_dir.join("server-instance-id")) else {
+ return false;
+ };
+ if live != *new_instance_id {
+ return false;
+ }
+ let Ok(bytes) = std::fs::read(auth_snapshot_path(state_dir)) else {
+ return false;
+ };
+ let Ok(plain) = open_blob(admin_key, &bytes) else {
+ return false;
+ };
+ let Ok(snapshot) = serde_json::from_slice::(&plain) else {
+ return false;
+ };
+ snapshot.instance_id == *new_instance_id
+}
+
+pub(crate) fn rotation_already_installed(state_dir: &Path, new_key: &str) -> bool {
+ key_matches_existing_snapshot(Some(state_dir), new_key)
+ && live_admin_key_matches(state_dir, new_key)
+}
+
+fn live_admin_key_matches(state_dir: &Path, new_key: &str) -> bool {
+ let Ok(raw) = std::fs::read(state_dir.join("admin.key")) else {
+ return false;
+ };
+ let Ok(text) = std::str::from_utf8(&raw) else {
+ return false;
+ };
+ text.trim().as_bytes() == new_key.trim().as_bytes()
+}
+
+pub(crate) fn key_matches_existing_snapshot(state_dir: Option<&Path>, key: &str) -> bool {
+ let Some(state_dir) = state_dir else {
+ return false;
+ };
+ let snapshot_path = auth_snapshot_path(state_dir);
+ if !snapshot_path.exists() {
+ return false;
+ }
+ let Ok(Credential::Admin(admin_key)) = parse_credential(key) else {
+ return false;
+ };
+ let Ok(bytes) = std::fs::read(&snapshot_path) else {
+ return false;
+ };
+ open_blob(&admin_key, &bytes).is_ok()
+}
+
+pub(crate) fn key_matches_existing_state(state_dir: Option<&Path>, key: &str) -> bool {
+ if key_matches_existing_snapshot(state_dir, key) {
+ return true;
+ }
+ let Some(state_dir) = state_dir else {
+ return false;
+ };
+ if auth_snapshot_path(state_dir).exists() {
+ return false;
+ }
+ let wal_path = auth_wal_path(state_dir);
+ if !wal_path.exists() {
+ return false;
+ }
+ let Ok(Credential::Admin(admin_key)) = parse_credential(key) else {
+ return false;
+ };
+ wal_decrypts_with_key(&wal_path, &admin_key)
+}
+
+fn wal_decrypts_with_key(path: &Path, admin_key: &AesKeyType) -> bool {
+ let Ok(mut file) = File::open(path) else {
+ return false;
+ };
+ let Ok(metadata) = file.metadata() else {
+ return false;
+ };
+ if metadata.len() == 0 {
+ return true;
+ }
+ let mut length = [0_u8; 4];
+ if file.read_exact(&mut length).is_err() {
+ return false;
+ }
+ let length = u32::from_be_bytes(length) as usize;
+ if length == 0 || length > 1024 * 1024 {
+ return false;
+ }
+ let mut sealed = vec![0_u8; length];
+ if file.read_exact(&mut sealed).is_err() {
+ return false;
+ }
+ open_blob(admin_key, &sealed).is_ok()
+}
+
+fn refuse_write_if_encrypted_state(path: &Path, force: bool) -> Result<(), AuthFailure> {
+ // Creating or replacing the live root while snapshot/WAL remain leaves
+ // those files encrypted under the previous key. Staging `admin.key.next`
+ // is the rotate path and must stay allowed.
+ let Some(state_dir) = path.parent() else {
+ return Ok(());
+ };
+ if !encrypted_auth_state_exists(state_dir) {
+ return Ok(());
+ }
+ Err(AuthFailure::new(
+ "administrator_key_state_exists",
+ format!(
+ "refusing to {} `{}` while encrypted auth state exists; use `pb-mapper admin root-key rotate` or `pb-mapper admin auth-state reset --confirm`",
+ if force { "replace" } else { "create" },
+ path.display()
+ ),
+ false,
+ ))
+}
diff --git a/crates/pb-mapper-auth/src/persistence/blob.rs b/crates/pb-mapper-auth/src/persistence/blob.rs
new file mode 100644
index 0000000..dbebd7f
--- /dev/null
+++ b/crates/pb-mapper-auth/src/persistence/blob.rs
@@ -0,0 +1,74 @@
+//! AEAD wrap/unwrap for snapshot and WAL payloads.
+use super::super::*;
+
+pub(crate) fn seal_blob(admin_key: &AesKeyType, plain: &[u8]) -> Result, AuthFailure> {
+ let key = LessSafeKey::new(
+ UnboundKey::new(&AES_256_GCM, admin_key)
+ .map_err(|_| AuthFailure::internal("failed to initialize state encryption key"))?,
+ );
+ let mut nonce_bytes = [0_u8; 12];
+ let mut rng = rand::rng();
+ for byte in &mut nonce_bytes {
+ *byte = rng.random();
+ }
+ let mut output = plain.to_vec();
+ key.seal_in_place_append_tag(
+ Nonce::assume_unique_for_key(nonce_bytes),
+ Aad::from(STATE_AAD),
+ &mut output,
+ )
+ .map_err(|_| AuthFailure::internal("failed to encrypt authentication state"))?;
+ let mut sealed = Vec::with_capacity(STATE_BLOB_MAGIC.len() + nonce_bytes.len() + output.len());
+ sealed.extend_from_slice(STATE_BLOB_MAGIC);
+ sealed.extend_from_slice(&nonce_bytes);
+ sealed.extend_from_slice(&output);
+ Ok(sealed)
+}
+
+pub(crate) fn open_blob(admin_key: &AesKeyType, sealed: &[u8]) -> Result, AuthFailure> {
+ if sealed.len() < STATE_BLOB_MAGIC.len() + 12 + AES_256_GCM.tag_len()
+ || &sealed[..STATE_BLOB_MAGIC.len()] != STATE_BLOB_MAGIC
+ {
+ return Err(AuthFailure::new(
+ "temporary_key_store_unavailable",
+ "authentication state blob has an invalid header",
+ false,
+ ));
+ }
+ let nonce_start = STATE_BLOB_MAGIC.len();
+ let nonce_end = nonce_start + 12;
+ // Unreachable: the length check above guarantees these 12 bytes exist. This
+ // parses a file that may have been truncated or corrupted, so it reports
+ // rather than panics.
+ let nonce_bytes: [u8; 12] = sealed[nonce_start..nonce_end].try_into().map_err(|_| {
+ AuthFailure::new(
+ "temporary_key_store_unavailable",
+ "authentication state blob has an invalid nonce",
+ false,
+ )
+ })?;
+ let mut plain = sealed[nonce_end..].to_vec();
+ let key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, admin_key).map_err(|_| {
+ AuthFailure::new(
+ "temporary_key_store_unavailable",
+ "failed to initialize state decryption key",
+ false,
+ )
+ })?);
+ let opened = key
+ .open_in_place(
+ Nonce::assume_unique_for_key(nonce_bytes),
+ Aad::from(STATE_AAD),
+ &mut plain,
+ )
+ .map_err(|_| {
+ AuthFailure::new(
+ "temporary_key_store_unavailable",
+ "authentication state integrity check failed",
+ false,
+ )
+ })?;
+ let len = opened.len();
+ plain.truncate(len);
+ Ok(plain)
+}
diff --git a/crates/pb-mapper-auth/src/persistence/fs.rs b/crates/pb-mapper-auth/src/persistence/fs.rs
new file mode 100644
index 0000000..c83100a
--- /dev/null
+++ b/crates/pb-mapper-auth/src/persistence/fs.rs
@@ -0,0 +1,210 @@
+//! Directory lock, atomic replace, and parent-directory durability.
+use super::super::*;
+use super::hex;
+
+/// Create the state directory and take `auth.lock` before any credential or
+/// snapshot file is read or written.
+pub(crate) fn prepare_state_dir_and_lock(state_dir: &Path) -> Result, AuthFailure> {
+ prepare_state_dir(state_dir)?;
+ Ok(Arc::new(acquire_state_dir_lock(state_dir)?))
+}
+
+pub fn acquire_state_dir_lock(state_dir: &Path) -> Result {
+ let path = state_dir.join("auth.lock");
+ let file = OpenOptions::new()
+ .create(true)
+ .read(true)
+ .write(true)
+ .truncate(false)
+ .open(&path)
+ .map_err(|error| {
+ AuthFailure::new(
+ "auth_state_unavailable",
+ format!("failed to open `{}`: {error}", path.display()),
+ false,
+ )
+ })?;
+ lock_exclusive_nonblock(&file).map_err(|error| {
+ AuthFailure::new(
+ "auth_state_locked",
+ format!(
+ "authentication state directory `{}` is already in use: {error}",
+ state_dir.display()
+ ),
+ false,
+ )
+ })?;
+ Ok(file)
+}
+
+fn lock_exclusive_nonblock(file: &File) -> std::io::Result<()> {
+ #[cfg(unix)]
+ {
+ unsafe extern "C" {
+ fn flock(fd: i32, operation: i32) -> i32;
+ }
+ const LOCK_EX: i32 = 2;
+ const LOCK_NB: i32 = 4;
+ use std::os::unix::io::AsRawFd;
+ if unsafe { flock(file.as_raw_fd(), LOCK_EX | LOCK_NB) } != 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ Ok(())
+ }
+ #[cfg(windows)]
+ {
+ use std::os::windows::io::AsRawHandle;
+ const LOCKFILE_FAIL_IMMEDIATELY: u32 = 0x1;
+ const LOCKFILE_EXCLUSIVE_LOCK: u32 = 0x2;
+ #[repr(C)]
+ struct Overlapped {
+ internal: usize,
+ internal_high: usize,
+ offset: u32,
+ offset_high: u32,
+ event: *mut core::ffi::c_void,
+ }
+ extern "system" {
+ fn LockFileEx(
+ file: *mut core::ffi::c_void,
+ flags: u32,
+ reserved: u32,
+ bytes_low: u32,
+ bytes_high: u32,
+ overlapped: *mut Overlapped,
+ ) -> i32;
+ }
+ let mut overlapped = Overlapped {
+ internal: 0,
+ internal_high: 0,
+ offset: 0,
+ offset_high: 0,
+ event: core::ptr::null_mut(),
+ };
+ let ok = unsafe {
+ LockFileEx(
+ file.as_raw_handle(),
+ LOCKFILE_FAIL_IMMEDIATELY | LOCKFILE_EXCLUSIVE_LOCK,
+ 0,
+ 1,
+ 0,
+ &mut overlapped,
+ )
+ };
+ if ok == 0 {
+ Err(std::io::Error::last_os_error())
+ } else {
+ Ok(())
+ }
+ }
+ #[cfg(not(any(unix, windows)))]
+ {
+ let _ = file;
+ Ok(())
+ }
+}
+
+/// `core`'s durability primitive, reported as an `AuthFailure`.
+pub(crate) fn sync_parent_directory(path: &Path) -> Result<(), AuthFailure> {
+ pb_mapper_core::durable_file::sync_parent_directory(path).map_err(|error| {
+ AuthFailure::new(
+ "temporary_key_store_unavailable",
+ format!("failed to sync `{}`: {error}", path.display()),
+ false,
+ )
+ })
+}
+
+pub(crate) fn prepare_state_dir(path: &Path) -> Result<(), AuthFailure> {
+ std::fs::create_dir_all(path).map_err(|error| {
+ AuthFailure::new(
+ "auth_state_unavailable",
+ format!(
+ "failed to create auth state directory `{}`: {error}",
+ path.display()
+ ),
+ false,
+ )
+ })?;
+ #[cfg(unix)]
+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err(|error| {
+ AuthFailure::new(
+ "auth_state_unavailable",
+ format!(
+ "failed to secure auth state directory `{}`: {error}",
+ path.display()
+ ),
+ false,
+ )
+ })?;
+ Ok(())
+}
+
+pub(crate) fn atomic_write(path: &Path, data: &[u8], mode: u32) -> Result<(), AuthFailure> {
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent).map_err(|error| {
+ AuthFailure::new(
+ "auth_state_unavailable",
+ format!("failed to create `{}`: {error}", parent.display()),
+ false,
+ )
+ })?;
+ }
+ let file_name = path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .unwrap_or("auth-state");
+ let mut random_suffix = [0_u8; 8];
+ let mut rng = rand::rng();
+ for byte in &mut random_suffix {
+ *byte = rng.random();
+ }
+ let temporary = path.with_file_name(format!(
+ ".{file_name}.tmp-{}-{}",
+ std::process::id(),
+ hex(&random_suffix)
+ ));
+ let mut file = OpenOptions::new()
+ .create_new(true)
+ .write(true)
+ .open(&temporary)
+ .map_err(|error| {
+ AuthFailure::new(
+ "auth_state_unavailable",
+ format!("failed to open `{}`: {error}", temporary.display()),
+ false,
+ )
+ })?;
+ let result = (|| {
+ #[cfg(unix)]
+ file.set_permissions(std::fs::Permissions::from_mode(mode))
+ .map_err(|error| {
+ AuthFailure::internal(format!("failed to set key permissions: {error}"))
+ })?;
+ #[cfg(not(unix))]
+ let _ = mode;
+ file.write_all(data)
+ .and_then(|()| file.sync_all())
+ .map_err(|error| {
+ AuthFailure::new(
+ "auth_state_unavailable",
+ format!("failed to write `{}`: {error}", temporary.display()),
+ false,
+ )
+ })?;
+ drop(file);
+ pb_mapper_core::durable_file::replace_file(&temporary, path).map_err(|error| {
+ AuthFailure::new(
+ "auth_state_unavailable",
+ format!("failed to replace `{}`: {error}", path.display()),
+ false,
+ )
+ })?;
+ sync_parent_directory(path)?;
+ Ok(())
+ })();
+ if result.is_err() {
+ let _ = std::fs::remove_file(&temporary);
+ }
+ result
+}
diff --git a/crates/pb-mapper-auth/src/persistence/mod.rs b/crates/pb-mapper-auth/src/persistence/mod.rs
new file mode 100644
index 0000000..144190d
--- /dev/null
+++ b/crates/pb-mapper-auth/src/persistence/mod.rs
@@ -0,0 +1,77 @@
+//! Durable, encrypted authentication state and audit/replay retention.
+//!
+//! ```text
+//! startup: lock -> admin.key -> recover instance id -> decrypt snapshot -> replay WAL
+//! mutation: command -> fsync encrypted WAL -> publish hot-state change
+//! compact: hot state + audit + replay set -> snapshot -> truncate WAL
+//! ```
+//!
+//! Snapshot replacement and administrator-key files use atomic rename. Bounded audit
+//! and replay collections are carried through compaction so security history does not
+//! disappear when the WAL is truncated.
+
+use super::*;
+
+mod admin_key;
+mod blob;
+mod fs;
+mod snapshot;
+mod wal;
+
+#[cfg(test)]
+pub(crate) use admin_key::read_instance_id_file;
+pub use admin_key::{generate_admin_key, initialize_admin_key, write_admin_key_file};
+pub(crate) use admin_key::{
+ key_matches_existing_state, load_or_create_instance_id, random_instance_id,
+ recover_instance_id_after_reset, reset_already_installed, rotation_already_installed,
+ write_admin_key,
+};
+pub(crate) use blob::{open_blob, seal_blob};
+pub use fs::acquire_state_dir_lock;
+#[cfg(test)]
+pub(crate) use fs::prepare_state_dir;
+pub(crate) use fs::sync_parent_directory;
+pub(crate) use fs::{atomic_write, prepare_state_dir_and_lock};
+#[cfg(test)]
+pub(crate) use snapshot::try_load_persisted_state;
+pub(crate) use snapshot::{
+ build_snapshot, cancel_all_temporary_leases, compaction_is_allowed, empty_snapshot,
+ load_persisted_state, normalize_tombstone_times, push_audit_record, push_persisted_audit,
+ split_high_slot_state,
+};
+pub(crate) use wal::{
+ append_audit, append_mutation, append_wal, fail_closed_on_uncertain_wal, read_wal,
+ truncate_auth_wal, write_snapshot_and_truncate_wal,
+};
+
+pub(crate) const AUTH_SNAPSHOT_FILE: &str = "auth.snapshot";
+pub(crate) const AUTH_WAL_FILE: &str = "auth.wal";
+
+pub(crate) fn auth_snapshot_path(state_dir: &Path) -> PathBuf {
+ state_dir.join(AUTH_SNAPSHOT_FILE)
+}
+
+pub(crate) fn auth_wal_path(state_dir: &Path) -> PathBuf {
+ state_dir.join(AUTH_WAL_FILE)
+}
+
+pub fn encrypted_auth_state_exists(state_dir: &Path) -> bool {
+ auth_snapshot_path(state_dir).exists() || auth_wal_path(state_dir).exists()
+}
+
+pub(crate) fn unix_seconds() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs()
+}
+
+pub(crate) fn hex(bytes: &[u8]) -> String {
+ const DIGITS: &[u8; 16] = b"0123456789abcdef";
+ let mut output = String::with_capacity(bytes.len() * 2);
+ for byte in bytes {
+ output.push(DIGITS[(byte >> 4) as usize] as char);
+ output.push(DIGITS[(byte & 0x0f) as usize] as char);
+ }
+ output
+}
diff --git a/crates/pb-mapper-auth/src/persistence/snapshot.rs b/crates/pb-mapper-auth/src/persistence/snapshot.rs
new file mode 100644
index 0000000..18d72f0
--- /dev/null
+++ b/crates/pb-mapper-auth/src/persistence/snapshot.rs
@@ -0,0 +1,284 @@
+//! Snapshot construction, load, and mutation replay onto persisted entries.
+use super::super::*;
+use super::{auth_snapshot_path, auth_wal_path, open_blob, read_wal};
+
+pub(crate) fn compaction_is_allowed(safe_mode: bool) -> bool {
+ !safe_mode
+}
+
+pub(crate) fn push_audit_record(inner: &AuthStateInner, record: AuditRecord) {
+ let mut records = inner.audit_records.write();
+ while records.len() >= AUDIT_RECORD_CAPACITY {
+ records.pop_front();
+ }
+ records.push_back(record);
+}
+
+pub(crate) fn cancel_all_temporary_leases(inner: &AuthStateInner) {
+ let slots = inner.slots();
+ for lease in slots.iter().filter_map(|slot| slot.lease.upgrade()) {
+ lease.cancel_rotated();
+ }
+}
+
+fn snapshot_generations(inner: &AuthStateInner) -> Vec {
+ let slots = inner.slots();
+ let extra = inner.high_slot_generations.read();
+ let mut generations = slots.iter().map(|slot| slot.generation).collect::>();
+ generations.extend_from_slice(&extra);
+ generations
+}
+
+pub(crate) fn split_high_slot_state(
+ snapshot: &PersistedSnapshot,
+ capacity: usize,
+) -> (Vec, Vec) {
+ let high_generations = snapshot.generations.get(capacity..).unwrap_or(&[]).to_vec();
+ let high_entries = snapshot
+ .entries
+ .iter()
+ .filter(|entry| entry.key_id.slot().as_index() >= capacity)
+ .cloned()
+ .collect();
+ (high_generations, high_entries)
+}
+
+pub(crate) fn build_snapshot(
+ inner: &AuthStateInner,
+ admin_replays: &VecDeque,
+) -> PersistedSnapshot {
+ let slots = inner.slots();
+ let cold = inner.cold();
+ let generations = snapshot_generations(inner);
+ let mut entries = slots
+ .iter()
+ .enumerate()
+ .filter_map(|(index, slot)| {
+ if slot.state == SlotState::Free {
+ return None;
+ }
+ let key_id = KeyId::new(slot.generation, SlotIndex::from_index(index));
+ let cold = cold.get(&key_id)?;
+ Some(PersistedEntry {
+ key_id,
+ state: slot.state,
+ issued_at: cold.issued_at,
+ expires_at: slot.expires_at,
+ label: cold.label.clone(),
+ tombstoned_at: (cold.tombstoned_at != 0).then_some(cold.tombstoned_at),
+ })
+ })
+ .collect::>();
+ entries.extend(inner.high().iter().cloned());
+ snapshot_with(
+ inner,
+ inner.instance_id(),
+ generations,
+ entries,
+ admin_replays,
+ )
+}
+
+pub(crate) fn normalize_tombstone_times(snapshot: &mut PersistedSnapshot, now: u64) -> bool {
+ let mut changed = false;
+ for entry in &mut snapshot.entries {
+ if entry.tombstoned_at.is_some() {
+ continue;
+ }
+ let tombstoned_at = match entry.state {
+ SlotState::Expired => Some(entry.expires_at),
+ SlotState::Revoked => snapshot
+ .audit_records
+ .iter()
+ .rev()
+ .find(|record| {
+ record.action == "temporary_key_revoke" && record.key_id == Some(entry.key_id)
+ })
+ .map(|record| record.at)
+ .or(Some(now)),
+ SlotState::Free | SlotState::Active => None,
+ };
+ if tombstoned_at.is_some() {
+ entry.tombstoned_at = tombstoned_at;
+ changed = true;
+ }
+ }
+ changed
+}
+
+pub(crate) fn empty_snapshot(
+ inner: &AuthStateInner,
+ instance_id: [u8; INSTANCE_ID_LEN],
+ admin_replays: &VecDeque,
+) -> PersistedSnapshot {
+ snapshot_with(
+ inner,
+ instance_id,
+ snapshot_generations(inner),
+ Vec::new(),
+ admin_replays,
+ )
+}
+
+fn snapshot_with(
+ inner: &AuthStateInner,
+ instance_id: [u8; INSTANCE_ID_LEN],
+ generations: Vec,
+ entries: Vec,
+ admin_replays: &VecDeque,
+) -> PersistedSnapshot {
+ PersistedSnapshot {
+ schema_version: SNAPSHOT_SCHEMA_VERSION,
+ instance_id,
+ generations,
+ entries,
+ legacy_protocol: if inner.legacy_protocol_allowed.load(Ordering::Acquire) {
+ LegacyProtocolPolicy::Allow
+ } else {
+ LegacyProtocolPolicy::Deny
+ },
+ admin_replays: admin_replays.iter().cloned().collect(),
+ audit_records: inner.audit_records.read().clone(),
+ root_epoch: inner.root_epoch.load(Ordering::Acquire),
+ }
+}
+
+pub(crate) fn load_persisted_state(
+ config: &AuthConfig,
+ admin_key: &AesKeyType,
+ instance_id: [u8; INSTANCE_ID_LEN],
+) -> (Option, bool) {
+ match try_load_persisted_state(config, admin_key, instance_id) {
+ Ok(state) => (Some(state), false),
+ Err(error) => {
+ tracing::error!(
+ event = "auth_state_safe_mode",
+ auth_stage = "state_load",
+ reason = %error.code,
+ error = %error,
+ "temporary key store failed closed in administrator safe mode"
+ );
+ (None, true)
+ }
+ }
+}
+
+pub(crate) fn try_load_persisted_state(
+ config: &AuthConfig,
+ admin_key: &AesKeyType,
+ instance_id: [u8; INSTANCE_ID_LEN],
+) -> Result {
+ let snapshot_path = auth_snapshot_path(&config.state_dir);
+ let mut snapshot = if snapshot_path.exists() {
+ let bytes = std::fs::read(&snapshot_path).map_err(|error| {
+ AuthFailure::new(
+ "temporary_key_store_unavailable",
+ format!("failed to read `{}`: {error}", snapshot_path.display()),
+ false,
+ )
+ })?;
+ let plain = open_blob(admin_key, &bytes)?;
+ serde_json::from_slice::(&plain).map_err(|error| {
+ AuthFailure::new(
+ "temporary_key_store_unavailable",
+ format!("failed to decode auth snapshot: {error}"),
+ false,
+ )
+ })?
+ } else {
+ PersistedSnapshot {
+ schema_version: SNAPSHOT_SCHEMA_VERSION,
+ instance_id,
+ generations: vec![Generation::FIRST; config.max_temporary_keys],
+ entries: Vec::new(),
+ legacy_protocol: config.legacy_protocol,
+ admin_replays: Vec::new(),
+ audit_records: VecDeque::new(),
+ root_epoch: 0,
+ }
+ };
+ if snapshot.schema_version != SNAPSHOT_SCHEMA_VERSION || snapshot.instance_id != instance_id {
+ return Err(AuthFailure::new(
+ "temporary_key_store_unavailable",
+ "auth snapshot schema or server instance id does not match",
+ false,
+ ));
+ }
+ if snapshot.generations.len() < config.max_temporary_keys {
+ snapshot
+ .generations
+ .resize(config.max_temporary_keys, Generation::FIRST);
+ }
+
+ let wal_path = auth_wal_path(&config.state_dir);
+ if wal_path.exists() {
+ for record in read_wal(&wal_path, admin_key)? {
+ match record {
+ WalRecord::Mutation { mutation, audit } => {
+ apply_persisted_mutation(&mut snapshot, mutation)?;
+ push_persisted_audit(&mut snapshot.audit_records, audit);
+ }
+ WalRecord::AdminReplay(record) => snapshot.admin_replays.push(record),
+ WalRecord::Audit(audit) => push_persisted_audit(&mut snapshot.audit_records, audit),
+ }
+ }
+ }
+ Ok(snapshot)
+}
+
+pub(crate) fn apply_persisted_mutation(
+ snapshot: &mut PersistedSnapshot,
+ mutation: StateMutation,
+) -> Result<(), AuthFailure> {
+ match mutation {
+ StateMutation::Issue(entry) => {
+ let index = entry.key_id.slot().as_index();
+ if snapshot.generations.len() <= index {
+ snapshot.generations.resize(index + 1, Generation::FIRST);
+ }
+ snapshot.generations[index] = entry.key_id.generation();
+ snapshot
+ .entries
+ .retain(|current| current.key_id.slot().as_index() != index);
+ snapshot.entries.push(entry);
+ }
+ StateMutation::Renew { key_id, expires_at } => {
+ let entry = snapshot_entry_mut(snapshot, key_id, "renew")?;
+ entry.expires_at = expires_at;
+ entry.state = SlotState::Active;
+ entry.tombstoned_at = None;
+ }
+ StateMutation::Revoke { key_id, at } => {
+ let entry = snapshot_entry_mut(snapshot, key_id, "revoke")?;
+ entry.state = SlotState::Revoked;
+ entry.tombstoned_at = Some(at);
+ }
+ StateMutation::LegacyProtocol(policy) => snapshot.legacy_protocol = policy,
+ }
+ Ok(())
+}
+
+fn snapshot_entry_mut<'a>(
+ snapshot: &'a mut PersistedSnapshot,
+ key_id: KeyId,
+ operation: &str,
+) -> Result<&'a mut PersistedEntry, AuthFailure> {
+ snapshot
+ .entries
+ .iter_mut()
+ .find(|entry| entry.key_id == key_id)
+ .ok_or_else(|| {
+ AuthFailure::new(
+ "temporary_key_store_unavailable",
+ format!("WAL {operation} record references an unknown key"),
+ false,
+ )
+ })
+}
+
+pub(crate) fn push_persisted_audit(records: &mut VecDeque, record: AuditRecord) {
+ while records.len() >= AUDIT_RECORD_CAPACITY {
+ records.pop_front();
+ }
+ records.push_back(record);
+}
diff --git a/crates/pb-mapper-auth/src/persistence/wal.rs b/crates/pb-mapper-auth/src/persistence/wal.rs
new file mode 100644
index 0000000..a049ab8
--- /dev/null
+++ b/crates/pb-mapper-auth/src/persistence/wal.rs
@@ -0,0 +1,226 @@
+//! Encrypted WAL append, replay, and snapshot compaction.
+use super::super::*;
+use super::{
+ atomic_write, auth_snapshot_path, auth_wal_path, cancel_all_temporary_leases, open_blob,
+ push_audit_record, seal_blob, sync_parent_directory,
+};
+
+pub(crate) fn fail_closed_on_uncertain_wal(
+ inner: &AuthStateInner,
+ result: Result<(), AuthFailure>,
+) -> Result<(), AuthFailure> {
+ if let Err(error) = &result
+ && !error.retryable
+ {
+ inner.safe_mode.store(true, Ordering::Release);
+ cancel_all_temporary_leases(inner);
+ }
+ result
+}
+
+pub(crate) fn append_mutation(
+ config: &AuthConfig,
+ inner: &AuthStateInner,
+ mutation: StateMutation,
+ audit: AuditRecord,
+) -> Result<(), AuthFailure> {
+ fail_closed_on_uncertain_wal(
+ inner,
+ append_wal(
+ config,
+ &inner.admin_key(),
+ &WalRecord::Mutation {
+ mutation,
+ audit: audit.clone(),
+ },
+ ),
+ )?;
+ push_audit_record(inner, audit);
+ Ok(())
+}
+
+pub(crate) fn append_audit(
+ config: &AuthConfig,
+ inner: &AuthStateInner,
+ audit: AuditRecord,
+) -> Result<(), AuthFailure> {
+ fail_closed_on_uncertain_wal(
+ inner,
+ append_wal(config, &inner.admin_key(), &WalRecord::Audit(audit.clone())),
+ )?;
+ push_audit_record(inner, audit);
+ Ok(())
+}
+
+pub(crate) fn append_wal(
+ config: &AuthConfig,
+ admin_key: &AesKeyType,
+ record: &WalRecord,
+) -> Result<(), AuthFailure> {
+ let plain = serde_json::to_vec(record).map_err(|error| {
+ AuthFailure::internal(format!("failed to encode auth WAL record: {error}"))
+ })?;
+ let sealed = seal_blob(admin_key, &plain)?;
+ let length = u32::try_from(sealed.len())
+ .map_err(|_| AuthFailure::internal("auth WAL record is too large"))?;
+ let path = auth_wal_path(&config.state_dir);
+ let created = !path.exists();
+ let mut file = OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(&path)
+ .map_err(|error| {
+ AuthFailure::new(
+ "temporary_key_store_unavailable",
+ format!("failed to open `{}`: {error}", path.display()),
+ true,
+ )
+ })?;
+ #[cfg(unix)]
+ file.set_permissions(std::fs::Permissions::from_mode(0o600))
+ .map_err(|error| {
+ AuthFailure::new(
+ "temporary_key_store_unavailable",
+ format!("failed to secure `{}`: {error}", path.display()),
+ false,
+ )
+ })?;
+ let start_len = file
+ .metadata()
+ .map(|metadata| metadata.len())
+ .map_err(|error| {
+ AuthFailure::new(
+ "temporary_key_store_unavailable",
+ format!("failed to inspect `{}`: {error}", path.display()),
+ true,
+ )
+ })?;
+ if let Err(error) = file
+ .write_all(&length.to_be_bytes())
+ .and_then(|()| file.write_all(&sealed))
+ .and_then(|()| file.sync_data())
+ {
+ // retryable == rolled_back. A later append can then start at a known
+ // good offset. If truncation fails, the next record would be unreadable.
+ let rolled_back = file
+ .set_len(start_len)
+ .and_then(|()| file.sync_data())
+ .is_ok();
+ return Err(AuthFailure::new(
+ "temporary_key_store_unavailable",
+ if rolled_back {
+ format!("failed to durably append `{}`: {error}", path.display())
+ } else {
+ format!(
+ "failed to durably append `{}` and could not restore the previous WAL length: {error}",
+ path.display()
+ )
+ },
+ rolled_back,
+ ));
+ }
+ if created {
+ sync_parent_directory(&path)?;
+ }
+ Ok(())
+}
+
+pub(crate) fn read_wal(path: &Path, admin_key: &AesKeyType) -> Result, AuthFailure> {
+ let mut file = File::open(path).map_err(|error| {
+ AuthFailure::new(
+ "temporary_key_store_unavailable",
+ format!("failed to open `{}`: {error}", path.display()),
+ false,
+ )
+ })?;
+ let mut records = Vec::new();
+ loop {
+ let mut length = [0_u8; 4];
+ match file.read(&mut length[..1]) {
+ Ok(0) => break,
+ Ok(1) => {}
+ Ok(_) => unreachable!("single-byte WAL prefix read"),
+ Err(error) => {
+ return Err(AuthFailure::new(
+ "temporary_key_store_unavailable",
+ format!("failed to read auth WAL length: {error}"),
+ false,
+ ));
+ }
+ }
+ file.read_exact(&mut length[1..]).map_err(|error| {
+ AuthFailure::new(
+ "temporary_key_store_unavailable",
+ format!("truncated auth WAL length: {error}"),
+ false,
+ )
+ })?;
+ let length = u32::from_be_bytes(length) as usize;
+ if length > 1024 * 1024 {
+ return Err(AuthFailure::new(
+ "temporary_key_store_unavailable",
+ "auth WAL record exceeds 1 MiB",
+ false,
+ ));
+ }
+ let mut sealed = vec![0_u8; length];
+ file.read_exact(&mut sealed).map_err(|error| {
+ AuthFailure::new(
+ "temporary_key_store_unavailable",
+ format!("truncated auth WAL record: {error}"),
+ false,
+ )
+ })?;
+ let plain = open_blob(admin_key, &sealed)?;
+ records.push(serde_json::from_slice(&plain).map_err(|error| {
+ AuthFailure::new(
+ "temporary_key_store_unavailable",
+ format!("failed to decode auth WAL record: {error}"),
+ false,
+ )
+ })?);
+ }
+ Ok(records)
+}
+
+pub(crate) fn write_snapshot_and_truncate_wal(
+ config: &AuthConfig,
+ admin_key: &AesKeyType,
+ snapshot: &PersistedSnapshot,
+) -> Result<(), AuthFailure> {
+ let plain = serde_json::to_vec(snapshot).map_err(|error| {
+ AuthFailure::internal(format!("failed to encode auth snapshot: {error}"))
+ })?;
+ let sealed = seal_blob(admin_key, &plain)?;
+ let snapshot_path = auth_snapshot_path(&config.state_dir);
+ atomic_write(&snapshot_path, &sealed, 0o600)?;
+ truncate_auth_wal(&config.state_dir)
+}
+
+pub(crate) fn truncate_auth_wal(state_dir: &Path) -> Result<(), AuthFailure> {
+ let wal_path = auth_wal_path(state_dir);
+ let created = !wal_path.exists();
+ let wal = OpenOptions::new()
+ .create(true)
+ .write(true)
+ .truncate(true)
+ .open(&wal_path)
+ .map_err(|error| {
+ AuthFailure::new(
+ "temporary_key_store_unavailable",
+ format!("failed to truncate `{}`: {error}", wal_path.display()),
+ true,
+ )
+ })?;
+ wal.sync_all().map_err(|error| {
+ AuthFailure::new(
+ "temporary_key_store_unavailable",
+ format!("failed to sync `{}`: {error}", wal_path.display()),
+ true,
+ )
+ })?;
+ if created {
+ sync_parent_directory(&wal_path)?;
+ }
+ Ok(())
+}
diff --git a/crates/pb-mapper-auth/src/runtime.rs b/crates/pb-mapper-auth/src/runtime.rs
new file mode 100644
index 0000000..0fb3266
--- /dev/null
+++ b/crates/pb-mapper-auth/src/runtime.rs
@@ -0,0 +1,638 @@
+//! Public authentication runtime facade and hot-path credential checks.
+//!
+//! ```text
+//! process credential + persisted state
+//! |
+//! v
+//! hot slot table (Weak leases) <---- request authentication
+//! |
+//! +----> lifecycle actor (strong leases + time wheel)
+//! ```
+//!
+//! Read-only authentication stays synchronous and allocation-light. Every administrator
+//! API captures a weak authority lease and sends it to the actor, where it is compared
+//! with the current lease immediately before the operation executes.
+
+use super::*;
+
+impl AuthRuntime {
+ pub async fn from_process(config: AuthConfig) -> Result {
+ let state_lock = prepare_state_dir_and_lock(&config.state_dir)?;
+ let credential = load_server_admin_credential(&config.state_dir)?;
+ let Credential::Admin(admin_key) = credential else {
+ return Err(AuthFailure::new(
+ "administrator_key_required",
+ "the relay server must start with the administrator credential",
+ false,
+ ));
+ };
+ Self::start_locked(admin_key, config, true, state_lock).await
+ }
+
+ /// Start an embedded relay with an administrator key owned only by its state directory.
+ ///
+ /// This deliberately leaves the process credential untouched because the containing UI uses
+ /// that credential for its outbound register, connect, status, and stream connections.
+ pub async fn from_isolated_state(config: AuthConfig) -> Result {
+ let state_lock = prepare_state_dir_and_lock(&config.state_dir)?;
+ let credential = load_isolated_server_admin_credential(&config.state_dir)?;
+ let Credential::Admin(admin_key) = credential else {
+ return Err(AuthFailure::new(
+ "administrator_key_required",
+ "the embedded relay must start with an administrator credential",
+ false,
+ ));
+ };
+ Self::start_locked(admin_key, config, false, state_lock).await
+ }
+
+ pub async fn start(admin_key: AesKeyType, config: AuthConfig) -> Result {
+ let state_lock = prepare_state_dir_and_lock(&config.state_dir)?;
+ Self::start_locked(admin_key, config, true, state_lock).await
+ }
+
+ async fn start_locked(
+ admin_key: AesKeyType,
+ config: AuthConfig,
+ sync_process_credential: bool,
+ state_lock: Arc,
+ ) -> Result {
+ let instance_id = load_or_create_instance_id(&config.state_dir)?;
+ let instance_id =
+ recover_instance_id_after_reset(&config.state_dir, &admin_key, instance_id)?;
+ let (mut loaded, safe_mode) = load_persisted_state(&config, &admin_key, instance_id);
+ let now = unix_seconds();
+ if let Some(state) = loaded.as_mut()
+ && normalize_tombstone_times(state, now)
+ {
+ write_snapshot_and_truncate_wal(&config, &admin_key, state)?;
+ }
+ let mut slots = (0..config.max_temporary_keys)
+ .map(|_| SlotHot::default())
+ .collect::>()
+ .into_boxed_slice();
+ let mut cold = HashMap::new();
+ let mut restored_leases = Vec::new();
+
+ let admin_lease = Arc::new(AuthLease::new(ADMIN_KEY_ID, u64::MAX));
+ if let Some(state) = loaded.as_ref() {
+ for (index, generation) in state.generations.iter().copied().enumerate() {
+ if let Some(slot) = slots.get_mut(index) {
+ slot.generation = generation;
+ }
+ }
+ for entry in &state.entries {
+ let index = entry.key_id.slot().as_index();
+ let Some(slot) = slots.get_mut(index) else {
+ continue;
+ };
+ if slot.generation != entry.key_id.generation() {
+ continue;
+ }
+ let state = if entry.state == SlotState::Active && entry.expires_at <= now {
+ SlotState::Expired
+ } else {
+ entry.state
+ };
+ slot.state = state;
+ slot.expires_at = entry.expires_at;
+ cold.insert(
+ entry.key_id,
+ ColdMetadata {
+ issued_at: entry.issued_at,
+ label: entry.label.clone(),
+ tombstoned_at: match state {
+ SlotState::Expired => entry.tombstoned_at.unwrap_or(entry.expires_at),
+ SlotState::Revoked => entry.tombstoned_at.unwrap_or(now),
+ SlotState::Free | SlotState::Active => 0,
+ },
+ },
+ );
+ if state == SlotState::Active {
+ let lease = Arc::new(AuthLease::new(entry.key_id, entry.expires_at));
+ slot.lease = Arc::downgrade(&lease);
+ // Held only until the schedule below adopts them; the wheel
+ // is the lasting owner.
+ restored_leases.push(lease);
+ }
+ }
+ }
+
+ let legacy_protocol = if safe_mode {
+ LegacyProtocolPolicy::Deny
+ } else {
+ loaded
+ .as_ref()
+ .map(|state| state.legacy_protocol)
+ .unwrap_or(config.legacy_protocol)
+ };
+ let mut admin_replay_order = loaded
+ .as_ref()
+ .map(|state| {
+ state
+ .admin_replays
+ .iter()
+ .filter(|record| record.within_retention(now))
+ .cloned()
+ .collect::>()
+ })
+ .unwrap_or_default();
+ while admin_replay_order.len() > ADMIN_REPLAY_CAPACITY {
+ admin_replay_order.pop_front();
+ }
+ let admin_replays = admin_replay_order
+ .iter()
+ .map(|record| record.fingerprint)
+ .collect::>();
+ let mut audit_records: VecDeque = loaded
+ .as_ref()
+ .map(|state| state.audit_records.iter().cloned().collect())
+ .unwrap_or_default();
+ while audit_records.len() > AUDIT_RECORD_CAPACITY {
+ audit_records.pop_front();
+ }
+ let (high_slot_generations, mut high_slot_entries) = loaded
+ .as_ref()
+ .map(|state| split_high_slot_state(state, config.max_temporary_keys))
+ .unwrap_or_default();
+ for entry in &mut high_slot_entries {
+ if entry.state == SlotState::Active && entry.expires_at <= now {
+ entry.state = SlotState::Expired;
+ entry.tombstoned_at = Some(entry.tombstoned_at.unwrap_or(entry.expires_at));
+ }
+ }
+ let inner = Arc::new(AuthStateInner {
+ admin: RwLock::new(AdminState {
+ key: admin_key,
+ lease: Arc::downgrade(&admin_lease),
+ }),
+ sync_process_credential,
+ instance_id: RwLock::new(instance_id),
+ slots: RwLock::new(slots),
+ high_slot_generations: RwLock::new(high_slot_generations),
+ high_slot_entries: RwLock::new(high_slot_entries),
+ safe_mode: AtomicBool::new(safe_mode),
+ legacy_protocol_allowed: AtomicBool::new(legacy_protocol.is_allowed()),
+ active_legacy_connections: AtomicU64::new(0),
+ last_legacy_connection_at: AtomicU64::new(0),
+ auth_successes: AtomicU64::new(0),
+ auth_failures: AtomicU64::new(0),
+ root_epoch: AtomicU64::new(loaded.as_ref().map(|state| state.root_epoch).unwrap_or(0)),
+ previous_root: RwLock::new(None),
+ audit_records: RwLock::new(audit_records),
+ cold: RwLock::new(cold),
+ });
+ let (command_tx, command_rx) = mpsc::channel(256);
+ let actor = tokio::spawn(run_auth_actor(
+ inner.clone(),
+ admin_lease,
+ command_rx,
+ config.clone(),
+ AuthActorState::new(
+ Leases::restored(&inner, now),
+ admin_replays,
+ admin_replay_order,
+ ),
+ state_lock.clone(),
+ ));
+ let actor_abort = actor.abort_handle();
+ let runtime = Self {
+ inner: Arc::downgrade(&inner),
+ command_tx,
+ config: config.clone(),
+ _state_lock: state_lock.clone(),
+ actor: Arc::new(Mutex::new(Some(actor))),
+ actor_abort,
+ };
+ Ok(runtime)
+ }
+
+ pub async fn shutdown_actor(&self) {
+ let (response, receiver) = oneshot::channel();
+ let _ = self
+ .command_tx
+ .send(AuthCommand::Shutdown { response })
+ .await;
+ let _ = receiver.await;
+ let handle = self.actor.lock().take();
+ if let Some(handle) = handle {
+ let _ = handle.await;
+ }
+ }
+
+ pub async fn abort_actor(&self) -> Result<(), AuthFailure> {
+ self.actor_abort.abort();
+ let handle = self.actor.lock().take();
+ if let Some(handle) = handle {
+ let _ = handle.await;
+ }
+ tokio::time::timeout(Duration::from_secs(5), async {
+ while self.inner.upgrade().is_some() {
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ }
+ })
+ .await
+ .map_err(|_| {
+ AuthFailure::new(
+ "auth_state_unavailable",
+ "authentication actor did not drop after abort",
+ true,
+ )
+ })
+ }
+
+ pub fn config(&self) -> &AuthConfig {
+ &self.config
+ }
+
+ fn inner(&self) -> Result, AuthFailure> {
+ self.inner.upgrade().ok_or_else(|| {
+ AuthFailure::new(
+ "auth_state_unavailable",
+ "authentication state manager is not running",
+ true,
+ )
+ })
+ }
+
+ pub fn admin_key(&self) -> Result {
+ Ok(self.inner()?.admin_key())
+ }
+
+ pub fn derive_key(&self, key_id: KeyId) -> Result {
+ let inner = self.inner()?;
+ if key_id.is_admin() {
+ return Ok(inner.admin_key());
+ }
+ derive_temporary_key(&inner.admin_key(), &inner.instance_id(), key_id)
+ }
+
+ #[cfg(test)]
+ pub(crate) fn high_slot_entry_count(&self) -> usize {
+ self.inner().map(|inner| inner.high().len()).unwrap_or(0)
+ }
+
+ pub fn derive_previous_key(&self, key_id: KeyId) -> Option {
+ let inner = self.inner().ok()?;
+ let previous = inner.previous_root.read().clone()?;
+ if key_id.is_admin() {
+ Some(previous.admin_key)
+ } else {
+ derive_temporary_key(&previous.admin_key, &previous.instance_id, key_id).ok()
+ }
+ }
+
+ pub fn authenticate_presented(
+ &self,
+ key_id: KeyId,
+ presented_key: &AesKeyType,
+ ) -> Result {
+ let inner = self.inner()?;
+ if key_id.is_admin() {
+ let admin = inner.admin.read();
+ if !bool::from(presented_key.ct_eq(&admin.key)) {
+ inner.auth_failures.fetch_add(1, Ordering::Relaxed);
+ return Err(AuthFailure::new(
+ "administrator_key_invalid",
+ "administrator credential does not match the active root key",
+ false,
+ ));
+ }
+ let lease = admin.lease.upgrade().ok_or_else(|| {
+ AuthFailure::new(
+ "administrator_key_rotated",
+ "administrator credential was rotated",
+ false,
+ )
+ })?;
+ inner.auth_successes.fetch_add(1, Ordering::Relaxed);
+ return Ok(AuthContext::from_lease(ADMIN_KEY_ID, true, &lease));
+ }
+ if inner.safe_mode.load(Ordering::Acquire) {
+ inner.auth_failures.fetch_add(1, Ordering::Relaxed);
+ return Err(AuthFailure::new(
+ "temporary_key_store_unavailable",
+ "temporary key state is unavailable; administrator reset is required",
+ false,
+ ));
+ }
+
+ let expected_key = derive_temporary_key(&inner.admin_key(), &inner.instance_id(), key_id)?;
+ if !bool::from(presented_key.ct_eq(&expected_key)) {
+ inner.auth_failures.fetch_add(1, Ordering::Relaxed);
+ return Err(temporary_key_material_mismatch(&inner, key_id));
+ }
+
+ let index = key_id.slot().as_index();
+ let generation = key_id.generation();
+ let slots = inner.slots();
+ let Some(slot) = slots.get(index) else {
+ inner.auth_failures.fetch_add(1, Ordering::Relaxed);
+ return Err(AuthFailure::new(
+ "temporary_key_not_found",
+ "temporary key id is outside the configured slot table",
+ false,
+ ));
+ };
+ if slot.generation != generation {
+ inner.auth_failures.fetch_add(1, Ordering::Relaxed);
+ return Err(AuthFailure::new(
+ "temporary_key_generation_mismatch",
+ "temporary key generation does not match the current slot",
+ false,
+ ));
+ }
+ let failure = match slot.state {
+ SlotState::Free => Some(AuthFailure::new(
+ "temporary_key_not_found",
+ "temporary key does not exist",
+ false,
+ )),
+ SlotState::Expired => Some(AuthFailure::new(
+ "temporary_key_expired",
+ "temporary key has expired",
+ false,
+ )),
+ SlotState::Revoked => Some(AuthFailure::new(
+ "temporary_key_revoked",
+ "temporary key was revoked",
+ false,
+ )),
+ SlotState::Active if slot.expires_at <= unix_seconds() => {
+ if let Some(lease) = slot.lease.upgrade() {
+ lease.cancel_expired();
+ }
+ Some(AuthFailure::new(
+ "temporary_key_expired",
+ "temporary key has expired",
+ false,
+ ))
+ }
+ SlotState::Active => None,
+ };
+ if let Some(failure) = failure {
+ inner.auth_failures.fetch_add(1, Ordering::Relaxed);
+ return Err(failure);
+ }
+ let lease = slot.lease.upgrade().ok_or_else(|| {
+ inner.auth_failures.fetch_add(1, Ordering::Relaxed);
+ AuthFailure::new(
+ "temporary_key_inactive",
+ "temporary key lease is no longer active",
+ true,
+ )
+ })?;
+ inner.auth_successes.fetch_add(1, Ordering::Relaxed);
+ Ok(AuthContext::from_lease(key_id, false, &lease))
+ }
+
+ pub fn legacy_protocol_allowed(&self) -> Result {
+ Ok(self
+ .inner()?
+ .legacy_protocol_allowed
+ .load(Ordering::Acquire))
+ }
+
+ pub fn record_legacy_connection(&self) -> Result {
+ let inner = self.inner()?;
+ inner
+ .active_legacy_connections
+ .fetch_add(1, Ordering::AcqRel);
+ inner
+ .last_legacy_connection_at
+ .store(unix_seconds(), Ordering::Release);
+ Ok(LegacyConnectionGuard {
+ inner: Arc::downgrade(&inner),
+ })
+ }
+
+ async fn request(
+ &self,
+ build: impl FnOnce(oneshot::Sender>) -> AuthCommand,
+ ) -> Result {
+ let (response, receiver) = oneshot::channel();
+ self.command_tx.send(build(response)).await.map_err(|_| {
+ AuthFailure::new(
+ "auth_state_unavailable",
+ "authentication state manager is not running",
+ true,
+ )
+ })?;
+ receiver.await.map_err(|_| {
+ AuthFailure::new(
+ "auth_state_unavailable",
+ "authentication state manager dropped the response",
+ true,
+ )
+ })?
+ }
+
+ pub async fn claim_admin_mutation(
+ &self,
+ authorization: &AuthContext,
+ fingerprint: [u8; 32],
+ client_timestamp: u64,
+ ) -> Result<(), AuthFailure> {
+ let authority = authorization.admin_authority()?;
+ self.request(|response| AuthCommand::ClaimAdminMutation {
+ authority,
+ fingerprint,
+ client_timestamp,
+ response,
+ })
+ .await
+ }
+
+ pub async fn issue(
+ &self,
+ authorization: &AuthContext,
+ ttl: Duration,
+ label: Option,
+ ) -> Result {
+ let authority = authorization.admin_authority()?;
+ self.request(|response| AuthCommand::Issue {
+ authority,
+ ttl,
+ label,
+ response,
+ })
+ .await
+ }
+
+ pub async fn list(
+ &self,
+ authorization: &AuthContext,
+ page: u32,
+ page_size: u16,
+ ) -> Result {
+ let authority = authorization.admin_authority()?;
+ self.request(|response| AuthCommand::List {
+ authority,
+ page,
+ page_size,
+ response,
+ })
+ .await
+ }
+
+ pub async fn show(
+ &self,
+ authorization: &AuthContext,
+ key_id: KeyId,
+ reveal: bool,
+ ) -> Result {
+ let authority = authorization.admin_authority()?;
+ self.request(|response| AuthCommand::Show {
+ authority,
+ key_id,
+ reveal,
+ response,
+ })
+ .await
+ }
+
+ pub async fn renew(
+ &self,
+ authorization: &AuthContext,
+ key_id: KeyId,
+ ttl: Duration,
+ ) -> Result {
+ let authority = authorization.admin_authority()?;
+ self.request(|response| AuthCommand::Renew {
+ authority,
+ key_id,
+ ttl,
+ response,
+ })
+ .await
+ }
+
+ pub async fn revoke(
+ &self,
+ authorization: &AuthContext,
+ key_id: KeyId,
+ ) -> Result {
+ let authority = authorization.admin_authority()?;
+ self.request(|response| AuthCommand::Revoke {
+ authority,
+ key_id,
+ response,
+ })
+ .await
+ }
+
+ pub async fn gc(&self, authorization: &AuthContext) -> Result {
+ let authority = authorization.admin_authority()?;
+ self.request(|response| AuthCommand::Gc {
+ authority,
+ response,
+ })
+ .await
+ }
+
+ pub async fn reset(&self, authorization: &AuthContext) -> Result<(), AuthFailure> {
+ let authority = authorization.admin_authority()?;
+ self.request(|response| AuthCommand::Reset {
+ authority,
+ response,
+ })
+ .await
+ }
+
+ pub async fn rotate_root(
+ &self,
+ authorization: &AuthContext,
+ new_key: AesKeyType,
+ ) -> Result<(), AuthFailure> {
+ let authority = authorization.admin_authority()?;
+ self.request(|response| AuthCommand::RotateRoot {
+ authority,
+ new_key,
+ response,
+ })
+ .await
+ }
+
+ pub async fn set_legacy_protocol(
+ &self,
+ authorization: &AuthContext,
+ policy: LegacyProtocolPolicy,
+ ) -> Result<(), AuthFailure> {
+ let authority = authorization.admin_authority()?;
+ self.request(|response| AuthCommand::SetLegacyProtocol {
+ authority,
+ policy,
+ response,
+ })
+ .await
+ }
+
+ pub async fn status(&self, authorization: &AuthContext) -> Result {
+ let authority = authorization.admin_authority()?;
+ self.request(|response| AuthCommand::Status {
+ authority,
+ response,
+ })
+ .await
+ }
+
+ pub async fn audit_admin(
+ &self,
+ authorization: &AuthContext,
+ action: impl Into,
+ key_id: Option,
+ detail: Option,
+ ) -> Result<(), AuthFailure> {
+ let authority = authorization.admin_authority()?;
+ let action = action.into();
+ self.request(|response| AuthCommand::Audit {
+ authority,
+ action,
+ key_id,
+ detail,
+ response,
+ })
+ .await
+ }
+}
+
+fn temporary_key_material_mismatch(inner: &AuthStateInner, key_id: KeyId) -> AuthFailure {
+ let index = key_id.slot().as_index();
+ let generation = key_id.generation();
+ let slots = inner.slots();
+ let current_generation = match slots.get(index) {
+ Some(slot) => Some(slot.generation),
+ None => {
+ let high = inner.high_slot_generations.read();
+ index
+ .checked_sub(slots.len())
+ .and_then(|offset| high.get(offset).copied())
+ }
+ };
+ let slot_is_active = slots
+ .get(index)
+ .is_some_and(|slot| slot.state == SlotState::Active && slot.generation == generation);
+ if slot_is_active {
+ return AuthFailure::new(
+ "temporary_key_invalid",
+ "temporary credential does not match the active relay key material",
+ false,
+ );
+ }
+ let current_epoch = inner.root_epoch.load(Ordering::Acquire);
+ if current_epoch > 0
+ && generation > Generation::FIRST
+ && current_generation.is_some_and(|issued| generation <= issued)
+ {
+ return AuthFailure::new(
+ "temporary_key_rotated",
+ "temporary credential was invalidated by administrator root rotation or auth-state reset",
+ false,
+ );
+ }
+ AuthFailure::new(
+ "temporary_key_invalid",
+ "temporary credential does not match the active relay key material",
+ false,
+ )
+}
diff --git a/crates/pb-mapper-auth/src/tests.rs b/crates/pb-mapper-auth/src/tests.rs
new file mode 100644
index 0000000..be2755b
--- /dev/null
+++ b/crates/pb-mapper-auth/src/tests.rs
@@ -0,0 +1,1571 @@
+//! Authentication invariants exercised at the state-machine boundary.
+//!
+//! ```text
+//! issue -> renew -> expire/revoke -> persist/restart
+//! | |
+//! +-> lease cancellation +-> encrypted recovery
+//! root rotate -> reject old key + reject already-authenticated old context
+//! ```
+//!
+//! Protocol framing has its own tests under `common::message::secure::tests`; this
+//! module focuses on lifecycle, persistence, audit, replay, and timing-wheel behavior.
+
+use pb_mapper_core::test_support::PROCESS_CREDENTIAL_TEST_LOCK;
+
+use super::*;
+
+fn temp_state_dir(name: &str) -> PathBuf {
+ let mut suffix = [0_u8; 8];
+ let mut rng = rand::rng();
+ for byte in &mut suffix {
+ *byte = rng.random();
+ }
+ std::env::temp_dir().join(format!("pb-mapper-{name}-{}", hex(&suffix)))
+}
+
+fn authenticate_for_test(runtime: &AuthRuntime, key_id: KeyId) -> Result {
+ let key = runtime.derive_key(key_id)?;
+ runtime.authenticate_presented(key_id, &key)
+}
+
+#[test]
+fn initialize_admin_key_refuses_to_replace_a_key_when_encrypted_state_exists() {
+ let state_dir = temp_state_dir("force-init-state");
+ std::fs::create_dir_all(&state_dir).unwrap();
+ let key_path = state_dir.join("admin.key");
+ std::fs::write(&key_path, b"0123456789abcdefghijklmnopqrstuv\n").unwrap();
+ std::fs::write(state_dir.join("auth.snapshot"), b"encrypted").unwrap();
+ let error = initialize_admin_key(&key_path, true).unwrap_err();
+ assert_eq!(error.code, "administrator_key_state_exists");
+ let missing = state_dir.join("missing-admin.key");
+ let error = initialize_admin_key(&missing, false).unwrap_err();
+ assert_eq!(error.code, "administrator_key_state_exists");
+ let error =
+ write_admin_key_file(&key_path, "abcdefghijklmnopqrstuvwxyz012345", true).unwrap_err();
+ assert_eq!(error.code, "administrator_key_state_exists");
+ write_admin_key_file(
+ &state_dir.join("admin.key.next"),
+ "abcdefghijklmnopqrstuvwxyz012345",
+ true,
+ )
+ .unwrap();
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[tokio::test]
+async fn shrinking_then_expanding_capacity_does_not_reuse_old_key_ids() {
+ let state_dir = temp_state_dir("capacity-shrink");
+ let admin_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let config_two = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 2,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let runtime = AuthRuntime::start(admin_key, config_two.clone())
+ .await
+ .unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ let first = runtime
+ .issue(&admin, Duration::from_secs(60), Some("first".to_string()))
+ .await
+ .unwrap();
+ let second = runtime
+ .issue(&admin, Duration::from_secs(60), Some("second".to_string()))
+ .await
+ .unwrap();
+ let Credential::Temporary {
+ key_id: first_id, ..
+ } = parse_credential(&first.credential).unwrap()
+ else {
+ panic!("expected temporary credential");
+ };
+ let Credential::Temporary {
+ key_id: second_id, ..
+ } = parse_credential(&second.credential).unwrap()
+ else {
+ panic!("expected temporary credential");
+ };
+ runtime
+ .revoke(&admin, KeyId::from_u64(first_id))
+ .await
+ .unwrap();
+ runtime
+ .revoke(&admin, KeyId::from_u64(second_id))
+ .await
+ .unwrap();
+ runtime.gc(&admin).await.unwrap();
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+
+ let config_one = AuthConfig {
+ max_temporary_keys: 1,
+ ..config_two.clone()
+ };
+ let runtime = AuthRuntime::start(admin_key, config_one).await.unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ assert!(!runtime.status(&admin).await.unwrap().safe_mode);
+ let _third = runtime
+ .issue(&admin, Duration::from_secs(60), Some("third".to_string()))
+ .await
+ .unwrap();
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+
+ let runtime = AuthRuntime::start(admin_key, config_two).await.unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ assert!(!runtime.status(&admin).await.unwrap().safe_mode);
+ let fourth = runtime
+ .issue(&admin, Duration::from_secs(60), Some("fourth".to_string()))
+ .await
+ .unwrap();
+ let Credential::Temporary {
+ key_id: fourth_id, ..
+ } = parse_credential(&fourth.credential).unwrap()
+ else {
+ panic!("expected temporary credential");
+ };
+ assert_ne!(fourth_id, second_id);
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[tokio::test]
+async fn gc_removes_inactive_high_slot_entries_and_keeps_their_generations() {
+ let state_dir = temp_state_dir("gc-high-slots");
+ let admin_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let config_two = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 2,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let runtime = AuthRuntime::start(admin_key, config_two.clone())
+ .await
+ .unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ let first = runtime
+ .issue(&admin, Duration::from_secs(60), Some("first".to_string()))
+ .await
+ .unwrap();
+ let second = runtime
+ .issue(&admin, Duration::from_secs(60), Some("second".to_string()))
+ .await
+ .unwrap();
+ let Credential::Temporary {
+ key_id: first_id, ..
+ } = parse_credential(&first.credential).unwrap()
+ else {
+ panic!("expected temporary credential");
+ };
+ let Credential::Temporary {
+ key_id: second_id, ..
+ } = parse_credential(&second.credential).unwrap()
+ else {
+ panic!("expected temporary credential");
+ };
+ runtime
+ .revoke(&admin, KeyId::from_u64(first_id))
+ .await
+ .unwrap();
+ runtime
+ .revoke(&admin, KeyId::from_u64(second_id))
+ .await
+ .unwrap();
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+
+ let config_one = AuthConfig {
+ max_temporary_keys: 1,
+ ..config_two.clone()
+ };
+ let runtime = AuthRuntime::start(admin_key, config_one).await.unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ assert_eq!(runtime.high_slot_entry_count(), 1);
+ let removed = runtime.gc(&admin).await.unwrap();
+ assert!(removed >= 1);
+ assert_eq!(runtime.high_slot_entry_count(), 0);
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+
+ let runtime = AuthRuntime::start(admin_key, config_two).await.unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ let _low = runtime
+ .issue(&admin, Duration::from_secs(60), Some("low".to_string()))
+ .await
+ .unwrap();
+ let high = runtime
+ .issue(&admin, Duration::from_secs(60), Some("high".to_string()))
+ .await
+ .unwrap();
+ let Credential::Temporary {
+ key_id: reused_id, ..
+ } = parse_credential(&high.credential).unwrap()
+ else {
+ panic!("expected temporary credential");
+ };
+ assert_ne!(reused_id, second_id);
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[tokio::test]
+async fn admin_lifecycle_covers_high_slot_keys_after_capacity_shrink() {
+ let state_dir = temp_state_dir("high-slot-admin");
+ let admin_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let config_two = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 2,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let runtime = AuthRuntime::start(admin_key, config_two.clone())
+ .await
+ .unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ let first = runtime
+ .issue(&admin, Duration::from_secs(60), Some("first".to_string()))
+ .await
+ .unwrap();
+ let second = runtime
+ .issue(&admin, Duration::from_secs(60), Some("second".to_string()))
+ .await
+ .unwrap();
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+
+ let config_one = AuthConfig {
+ max_temporary_keys: 1,
+ ..config_two.clone()
+ };
+ let runtime = AuthRuntime::start(admin_key, config_one).await.unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ assert_eq!(runtime.high_slot_entry_count(), 1);
+ let high_id = [first.metadata.key_id, second.metadata.key_id]
+ .into_iter()
+ .find(|key_id| key_id.slot().as_index() >= 1)
+ .expect("one issued key should land above the shrunken table");
+ let page = runtime.list(&admin, 0, 100).await.unwrap();
+ assert_eq!(page.items.len(), 2);
+ assert!(page.items.iter().any(|item| item.key_id == high_id));
+ let shown = runtime.show(&admin, high_id, false).await.unwrap();
+ assert_eq!(shown.metadata.key_id, high_id);
+ assert_eq!(shown.metadata.state, "active");
+ assert_eq!(
+ authenticate_for_test(&runtime, high_id).unwrap_err().code,
+ "temporary_key_not_found"
+ );
+ let status = runtime.status(&admin).await.unwrap();
+ assert_eq!(status.active_keys, 2);
+ let renewed = runtime
+ .renew(&admin, high_id, Duration::from_secs(120))
+ .await
+ .unwrap();
+ assert!(renewed.metadata.expires_at > shown.metadata.expires_at);
+ runtime.revoke(&admin, high_id).await.unwrap();
+ let revoked = runtime.show(&admin, high_id, false).await.unwrap();
+ assert_eq!(revoked.metadata.state, "revoked");
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+
+ let runtime = AuthRuntime::start(admin_key, config_two).await.unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ let restored = runtime.show(&admin, high_id, false).await.unwrap();
+ assert_eq!(restored.metadata.state, "revoked");
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[tokio::test]
+async fn safe_mode_denies_legacy_protocol_instead_of_restoring_the_default() {
+ let state_dir = temp_state_dir("safe-mode-legacy");
+ let admin_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 4,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let runtime = AuthRuntime::start(admin_key, config.clone()).await.unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ runtime
+ .set_legacy_protocol(&admin, LegacyProtocolPolicy::Deny)
+ .await
+ .unwrap();
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ std::fs::write(state_dir.join("auth.wal"), b"broken-wal").unwrap();
+
+ let runtime = AuthRuntime::start(admin_key, config).await.unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ let status = runtime.status(&admin).await.unwrap();
+ assert!(status.safe_mode);
+ assert_eq!(status.legacy_protocol, LegacyProtocolPolicy::Deny);
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[tokio::test]
+async fn overlapping_runtimes_cannot_share_an_auth_state_directory() {
+ let state_dir = temp_state_dir("auth-dir-lock");
+ let admin_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 4,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let first = AuthRuntime::start(admin_key, config.clone()).await.unwrap();
+ let error = match AuthRuntime::start(admin_key, config.clone()).await {
+ Ok(_) => panic!("second runtime should not share the auth directory"),
+ Err(error) => error,
+ };
+ assert_eq!(error.code, "auth_state_locked");
+ drop(first);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ let recovered = AuthRuntime::start(admin_key, config).await.unwrap();
+ drop(recovered);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[tokio::test]
+async fn env_recovery_key_is_not_written_when_it_cannot_decrypt_existing_state() {
+ let _process_credential_guard = PROCESS_CREDENTIAL_TEST_LOCK.lock().await;
+ let state_dir = temp_state_dir("env-key-must-match-snapshot");
+ prepare_state_dir(&state_dir).unwrap();
+ let good = *b"0123456789abcdefghijklmnopqrstuv";
+ let bad = *b"abcdefghijklmnopqrstuvwxyz012345";
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 1,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let snapshot = PersistedSnapshot {
+ schema_version: SNAPSHOT_SCHEMA_VERSION,
+ instance_id: [4_u8; INSTANCE_ID_LEN],
+ generations: vec![Generation::FIRST; 1],
+ entries: Vec::new(),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ admin_replays: Vec::new(),
+ audit_records: VecDeque::new(),
+ root_epoch: 0,
+ };
+ write_snapshot_and_truncate_wal(&config, &good, &snapshot).unwrap();
+ set_process_msg_header_key(Some(std::str::from_utf8(&bad).unwrap())).unwrap();
+ // SAFETY: this test holds `PROCESS_CREDENTIAL_TEST_LOCK`, which
+ // serialises every test that touches the process credential.
+ unsafe {
+ std::env::set_var(ENV_MSG_HEADER_KEY, std::str::from_utf8(&bad).unwrap());
+ };
+ let error = match AuthRuntime::from_process(config).await {
+ Ok(_) => panic!("a mismatched recovery key must not start the runtime"),
+ Err(error) => error,
+ };
+ // SAFETY: this test holds `PROCESS_CREDENTIAL_TEST_LOCK`, which
+ // serialises every test that touches the process credential.
+ unsafe {
+ std::env::remove_var(ENV_MSG_HEADER_KEY);
+ };
+ set_process_msg_header_key(None).unwrap();
+ assert_eq!(error.code, "administrator_key_invalid");
+ assert!(
+ !state_dir.join("admin.key").exists(),
+ "a mismatched MSG_HEADER_KEY must not become the live administrator key"
+ );
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[tokio::test]
+async fn env_recovery_key_is_accepted_for_wal_only_state() {
+ let _process_credential_guard = PROCESS_CREDENTIAL_TEST_LOCK.lock().await;
+ let state_dir = temp_state_dir("env-key-matches-wal");
+ prepare_state_dir(&state_dir).unwrap();
+ let good = *b"0123456789abcdefghijklmnopqrstuv";
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 1,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ append_wal(
+ &config,
+ &good,
+ &WalRecord::Audit(AuditRecord {
+ at: 1,
+ action: "issue".to_string(),
+ key_id: None,
+ label: None,
+ }),
+ )
+ .unwrap();
+ set_process_msg_header_key(Some(std::str::from_utf8(&good).unwrap())).unwrap();
+ // SAFETY: this test holds `PROCESS_CREDENTIAL_TEST_LOCK`, which
+ // serialises every test that touches the process credential.
+ unsafe {
+ std::env::set_var(ENV_MSG_HEADER_KEY, std::str::from_utf8(&good).unwrap());
+ };
+ let started = AuthRuntime::from_process(config).await;
+ // SAFETY: this test holds `PROCESS_CREDENTIAL_TEST_LOCK`, which
+ // serialises every test that touches the process credential.
+ unsafe {
+ std::env::remove_var(ENV_MSG_HEADER_KEY);
+ };
+ set_process_msg_header_key(None).unwrap();
+ started.expect("a matching recovery key must start from WAL-only state");
+ assert!(
+ state_dir.join("admin.key").exists(),
+ "a matching MSG_HEADER_KEY should become the live administrator key"
+ );
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[tokio::test]
+async fn env_recovery_key_is_not_written_when_wal_only_state_does_not_match() {
+ let _process_credential_guard = PROCESS_CREDENTIAL_TEST_LOCK.lock().await;
+ let state_dir = temp_state_dir("env-key-must-match-wal");
+ prepare_state_dir(&state_dir).unwrap();
+ let good = *b"0123456789abcdefghijklmnopqrstuv";
+ let bad = *b"abcdefghijklmnopqrstuvwxyz012345";
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 1,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ append_wal(
+ &config,
+ &good,
+ &WalRecord::Audit(AuditRecord {
+ at: 1,
+ action: "issue".to_string(),
+ key_id: None,
+ label: None,
+ }),
+ )
+ .unwrap();
+ set_process_msg_header_key(Some(std::str::from_utf8(&bad).unwrap())).unwrap();
+ // SAFETY: this test holds `PROCESS_CREDENTIAL_TEST_LOCK`, which
+ // serialises every test that touches the process credential.
+ unsafe {
+ std::env::set_var(ENV_MSG_HEADER_KEY, std::str::from_utf8(&bad).unwrap());
+ };
+ let error = match AuthRuntime::from_process(config).await {
+ Ok(_) => panic!("a mismatched recovery key must not start from WAL-only state"),
+ Err(error) => error,
+ };
+ // SAFETY: this test holds `PROCESS_CREDENTIAL_TEST_LOCK`, which
+ // serialises every test that touches the process credential.
+ unsafe {
+ std::env::remove_var(ENV_MSG_HEADER_KEY);
+ };
+ set_process_msg_header_key(None).unwrap();
+ assert_eq!(error.code, "administrator_key_invalid");
+ assert!(
+ !state_dir.join("admin.key").exists(),
+ "a mismatched MSG_HEADER_KEY must not become the live administrator key"
+ );
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[tokio::test]
+async fn from_isolated_state_takes_the_state_lock_before_creating_admin_key() {
+ let state_dir = temp_state_dir("lock-before-key");
+ prepare_state_dir(&state_dir).unwrap();
+ let _lock = acquire_state_dir_lock(&state_dir).unwrap();
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 4,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let error = match AuthRuntime::from_isolated_state(config).await {
+ Ok(_) => panic!("a locked start should not create a second runtime"),
+ Err(error) => error,
+ };
+ assert_eq!(error.code, "auth_state_locked");
+ assert!(
+ !state_dir.join("admin.key").exists(),
+ "a locked start must not create a competing administrator key"
+ );
+ drop(_lock);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[test]
+fn safe_mode_startup_does_not_allow_compaction() {
+ assert!(!compaction_is_allowed(true));
+ assert!(compaction_is_allowed(false));
+}
+
+#[tokio::test]
+async fn reset_clears_retained_high_slot_entries() {
+ let state_dir = temp_state_dir("reset-high-slots");
+ let admin_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let config_two = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 2,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let runtime = AuthRuntime::start(admin_key, config_two.clone())
+ .await
+ .unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ let first = runtime
+ .issue(&admin, Duration::from_secs(60), Some("first".to_string()))
+ .await
+ .unwrap();
+ let second = runtime
+ .issue(&admin, Duration::from_secs(60), Some("second".to_string()))
+ .await
+ .unwrap();
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+
+ let config_one = AuthConfig {
+ max_temporary_keys: 1,
+ ..config_two.clone()
+ };
+ let runtime = AuthRuntime::start(admin_key, config_one).await.unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ runtime.reset(&admin).await.unwrap();
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+
+ let runtime = AuthRuntime::start(admin_key, config_two).await.unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ let page = runtime.list(&admin, 0, 100).await.unwrap();
+ assert!(page.items.is_empty());
+ assert!(authenticate_for_test(&runtime, first.metadata.key_id).is_err());
+ assert!(authenticate_for_test(&runtime, second.metadata.key_id).is_err());
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[tokio::test]
+async fn rotate_root_rejects_a_nul_containing_key() {
+ let state_dir = temp_state_dir("rotate-nul");
+ let admin_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 4,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let runtime = AuthRuntime::start(admin_key, config).await.unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ let mut bad = *b"0123456789abcdefghijklmnopqrstuv";
+ bad[4] = 0;
+ let error = runtime.rotate_root(&admin, bad).await.unwrap_err();
+ assert_eq!(error.code, "administrator_key_invalid");
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[test]
+fn platform_default_auth_state_dir_is_writable_outside_linux_system_paths() {
+ let dir = platform_default_auth_state_dir();
+ #[cfg(windows)]
+ {
+ assert!(
+ dir.ends_with(std::path::Path::new("pb-mapper").join("auth")),
+ "windows default auth dir should be under a user-writable pb-mapper path: {}",
+ dir.display()
+ );
+ assert_ne!(dir, PathBuf::from(r"\var\lib\pb-mapper\auth"));
+ }
+ #[cfg(target_os = "macos")]
+ {
+ assert!(
+ dir.ends_with("Library/Application Support/pb-mapper/auth")
+ || dir == PathBuf::from("/Library/Application Support/pb-mapper/auth"),
+ "macos default auth dir should be under Application Support: {}",
+ dir.display()
+ );
+ }
+ #[cfg(not(any(windows, target_os = "macos")))]
+ {
+ let expected = linux_default_auth_state_dir(
+ unix_effective_uid(),
+ linux_system_auth_dir_usable(),
+ std::env::var_os("XDG_DATA_HOME").as_deref(),
+ std::env::var_os("HOME").as_deref(),
+ );
+ assert_eq!(dir, expected);
+ if unix_effective_uid() != 0 && !linux_system_auth_dir_usable() {
+ assert_ne!(
+ dir,
+ PathBuf::from(DEFAULT_AUTH_STATE_DIR),
+ "unprivileged Linux should not default to the system auth directory: {}",
+ dir.display()
+ );
+ }
+ }
+}
+
+#[test]
+fn sync_parent_directory_succeeds_for_a_local_file() {
+ let state_dir = temp_state_dir("dirsync");
+ prepare_state_dir(&state_dir).unwrap();
+ let path = state_dir.join("probe");
+ std::fs::write(&path, b"x").unwrap();
+ sync_parent_directory(&path).unwrap();
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[cfg(not(any(windows, target_os = "macos")))]
+#[test]
+fn linux_default_auth_state_dir_prefers_user_data_when_system_dir_is_unusable() {
+ assert_eq!(
+ linux_default_auth_state_dir(0, false, None, Some(std::ffi::OsStr::new("/home/op"))),
+ PathBuf::from(DEFAULT_AUTH_STATE_DIR)
+ );
+ assert_eq!(
+ linux_default_auth_state_dir(1000, true, None, Some(std::ffi::OsStr::new("/home/op"))),
+ PathBuf::from(DEFAULT_AUTH_STATE_DIR)
+ );
+ assert_eq!(
+ linux_default_auth_state_dir(
+ 1000,
+ false,
+ Some(std::ffi::OsStr::new("/xdg")),
+ Some(std::ffi::OsStr::new("/home/op"))
+ ),
+ PathBuf::from("/xdg/pb-mapper/auth")
+ );
+ assert_eq!(
+ linux_default_auth_state_dir(1000, false, None, Some(std::ffi::OsStr::new("/home/op"))),
+ PathBuf::from("/home/op/.local/share/pb-mapper/auth")
+ );
+ assert_eq!(
+ linux_default_auth_state_dir(1000, false, None, None),
+ PathBuf::from(DEFAULT_AUTH_STATE_DIR)
+ );
+}
+
+#[test]
+fn legacy_protocol_policy_trims_valid_values_and_rejects_unknown_values() {
+ assert_eq!(
+ parse_legacy_protocol_policy(" allow\n"),
+ Some(LegacyProtocolPolicy::Allow)
+ );
+ assert_eq!(
+ parse_legacy_protocol_policy(" DENY "),
+ Some(LegacyProtocolPolicy::Deny)
+ );
+ assert_eq!(parse_legacy_protocol_policy("enabled"), None);
+ assert_eq!(parse_legacy_protocol_policy(""), None);
+}
+
+#[test]
+fn key_id_serializes_as_a_plain_integer() {
+ let key_id = KeyId::new(Generation::from_u32(3), SlotIndex::from_index(2));
+ assert_eq!(serde_json::to_string(&key_id).unwrap(), "12884901890");
+ assert_eq!(
+ serde_json::from_str::("12884901890").unwrap(),
+ key_id
+ );
+ assert_eq!(
+ serde_json::to_string(&Generation::from_u32(7)).unwrap(),
+ "7"
+ );
+}
+
+#[test]
+fn key_id_round_trip() {
+ let key_id = KeyId::new(Generation::from_u32(42), SlotIndex::from_index(65_535));
+ assert_eq!(key_id.generation(), Generation::from_u32(42));
+ assert_eq!(key_id.slot(), SlotIndex::from_index(65_535));
+}
+
+#[test]
+fn derived_key_is_bound_to_instance_and_key_id() {
+ let admin_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let instance_a = [1_u8; INSTANCE_ID_LEN];
+ let instance_b = [2_u8; INSTANCE_ID_LEN];
+ let key = derive_temporary_key(
+ &admin_key,
+ &instance_a,
+ KeyId::new(Generation::from_u32(1), SlotIndex::from_index(7)),
+ )
+ .unwrap();
+ assert_eq!(
+ key,
+ derive_temporary_key(
+ &admin_key,
+ &instance_a,
+ KeyId::new(Generation::from_u32(1), SlotIndex::from_index(7))
+ )
+ .unwrap()
+ );
+ assert_ne!(
+ key,
+ derive_temporary_key(
+ &admin_key,
+ &instance_b,
+ KeyId::new(Generation::from_u32(1), SlotIndex::from_index(7))
+ )
+ .unwrap()
+ );
+ assert_ne!(
+ key,
+ derive_temporary_key(
+ &admin_key,
+ &instance_a,
+ KeyId::new(Generation::from_u32(2), SlotIndex::from_index(7))
+ )
+ .unwrap()
+ );
+}
+
+#[tokio::test]
+async fn isolated_runtime_preserves_remote_temporary_process_credential() {
+ let _process_credential_guard = PROCESS_CREDENTIAL_TEST_LOCK.lock().await;
+ let state_dir = temp_state_dir("isolated-relay");
+ let temporary_key_id = KeyId::new(Generation::from_u32(1), SlotIndex::from_index(0));
+ let temporary_key = *b"temporary-remote-key-0123456789a";
+ let temporary_credential =
+ encode_temporary_credential(temporary_key_id.as_u64(), &temporary_key);
+ set_process_msg_header_key(Some(&temporary_credential)).unwrap();
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 4,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+
+ let runtime = AuthRuntime::from_isolated_state(config).await.unwrap();
+ assert_eq!(
+ get_process_credential().unwrap(),
+ Credential::Temporary {
+ key_id: temporary_key_id.as_u64(),
+ key: temporary_key,
+ }
+ );
+
+ let local_admin_raw = std::fs::read_to_string(state_dir.join("admin.key")).unwrap();
+ let Credential::Admin(local_admin_key) = parse_credential(local_admin_raw.trim()).unwrap()
+ else {
+ panic!("isolated relay key should be an administrator credential");
+ };
+ let local_admin = runtime
+ .authenticate_presented(ADMIN_KEY_ID, &local_admin_key)
+ .unwrap();
+ runtime
+ .rotate_root(&local_admin, *b"isolated-new-admin-key-012345678")
+ .await
+ .unwrap();
+ assert_eq!(
+ get_process_credential().unwrap(),
+ Credential::Temporary {
+ key_id: temporary_key_id.as_u64(),
+ key: temporary_key,
+ }
+ );
+
+ set_process_msg_header_key(None).unwrap();
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[tokio::test]
+async fn issue_renew_revoke_and_persist() {
+ let state_dir = temp_state_dir("auth-lifecycle");
+ let admin_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 8,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let runtime = AuthRuntime::start(admin_key, config.clone()).await.unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ let issued = runtime
+ .issue(&admin, Duration::from_secs(60), Some("demo".to_string()))
+ .await
+ .unwrap();
+ assert!(issued.credential.starts_with("pbmt1_"));
+ let context = authenticate_for_test(&runtime, issued.metadata.key_id).unwrap();
+ assert!(!context.is_admin);
+ let cancellation = context.cancellation_token().unwrap();
+ let renewed = runtime
+ .renew(&admin, issued.metadata.key_id, Duration::from_secs(120))
+ .await
+ .unwrap();
+ assert_eq!(renewed.metadata.key_id, issued.metadata.key_id);
+ assert_eq!(renewed.credential, issued.credential);
+ assert!(renewed.metadata.expires_at > issued.metadata.expires_at);
+ let presented = runtime.derive_key(issued.metadata.key_id).unwrap();
+ runtime
+ .revoke(&admin, issued.metadata.key_id)
+ .await
+ .unwrap();
+ let mut mistyped = presented;
+ mistyped[0] ^= 0x01;
+ assert_eq!(
+ runtime
+ .authenticate_presented(issued.metadata.key_id, &mistyped)
+ .unwrap_err()
+ .code,
+ "temporary_key_invalid"
+ );
+ assert!(cancellation.is_cancelled());
+ assert_eq!(
+ context.ensure_active().unwrap_err().code,
+ "temporary_key_revoked"
+ );
+ assert_eq!(
+ authenticate_for_test(&runtime, issued.metadata.key_id)
+ .unwrap_err()
+ .code,
+ "temporary_key_revoked"
+ );
+ let instance_id = load_or_create_instance_id(&state_dir).unwrap();
+ let persisted = try_load_persisted_state(&config, &admin_key, instance_id).unwrap();
+ let revoked = persisted
+ .entries
+ .iter()
+ .find(|entry| entry.key_id == issued.metadata.key_id)
+ .unwrap();
+ assert_eq!(revoked.state, SlotState::Revoked);
+ assert!(revoked.tombstoned_at.is_some());
+ drop(runtime);
+
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ let restored = AuthRuntime::start(admin_key, config).await.unwrap();
+ assert_eq!(
+ authenticate_for_test(&restored, issued.metadata.key_id)
+ .unwrap_err()
+ .code,
+ "temporary_key_revoked"
+ );
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[tokio::test]
+async fn ensure_active_keeps_expiry_after_the_lease_is_cancelled() {
+ let state_dir = temp_state_dir("lease-expiry-reason");
+ let admin_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 4,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let runtime = AuthRuntime::start(admin_key, config).await.unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ let issued = runtime
+ .issue(&admin, Duration::from_secs(60), Some("exp".to_string()))
+ .await
+ .unwrap();
+ let context = authenticate_for_test(&runtime, issued.metadata.key_id).unwrap();
+ let lease = context.ensure_active().unwrap();
+ lease.expire_now();
+ assert_eq!(
+ context.ensure_active().unwrap_err().code,
+ "temporary_key_expired"
+ );
+ assert_eq!(
+ context.ensure_active().unwrap_err().code,
+ "temporary_key_expired"
+ );
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[tokio::test]
+async fn renew_replaces_a_lease_canceled_during_persistence() {
+ let state_dir = temp_state_dir("renew-canceled-lease");
+ let admin_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 4,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let runtime = AuthRuntime::start(admin_key, config).await.unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ let issued = runtime
+ .issue(&admin, Duration::from_secs(60), Some("renew".to_string()))
+ .await
+ .unwrap();
+ let context = authenticate_for_test(&runtime, issued.metadata.key_id).unwrap();
+ let canceled = context.cancellation_token().unwrap();
+ canceled.cancel();
+ assert!(canceled.is_cancelled());
+
+ let renewed = runtime
+ .renew(&admin, issued.metadata.key_id, Duration::from_secs(120))
+ .await
+ .unwrap();
+ assert_eq!(renewed.metadata.key_id, issued.metadata.key_id);
+ let restored = authenticate_for_test(&runtime, issued.metadata.key_id).unwrap();
+ assert!(!restored.cancellation_token().unwrap().is_cancelled());
+ assert!(canceled.is_cancelled());
+
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[tokio::test]
+async fn reset_rotates_instance_and_prevents_old_key_id_reuse() {
+ let state_dir = temp_state_dir("auth-reset");
+ let admin_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 1,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let runtime = AuthRuntime::start(admin_key, config).await.unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ let before = runtime.status(&admin).await.unwrap().server_instance_id;
+ let old = runtime
+ .issue(
+ &admin,
+ Duration::from_secs(60),
+ Some("before-reset".to_string()),
+ )
+ .await
+ .unwrap();
+ let old_context = authenticate_for_test(&runtime, old.metadata.key_id).unwrap();
+ let old_cancellation = old_context.cancellation_token().unwrap();
+ let old_presented = runtime.derive_key(old.metadata.key_id).unwrap();
+
+ runtime.reset(&admin).await.unwrap();
+
+ let after = runtime.status(&admin).await.unwrap().server_instance_id;
+ assert_ne!(after, before);
+ assert!(old_cancellation.is_cancelled());
+ assert_eq!(
+ runtime
+ .authenticate_presented(old.metadata.key_id, &old_presented)
+ .unwrap_err()
+ .code,
+ "temporary_key_rotated"
+ );
+ let replacement = runtime
+ .issue(
+ &admin,
+ Duration::from_secs(60),
+ Some("after-reset".to_string()),
+ )
+ .await
+ .unwrap();
+ assert_ne!(replacement.metadata.key_id, old.metadata.key_id);
+ assert_ne!(replacement.credential, old.credential);
+
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[test]
+fn recover_instance_id_promotes_next_when_snapshot_matches() {
+ let state_dir = temp_state_dir("instance-next-promote");
+ prepare_state_dir(&state_dir).unwrap();
+ let admin_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let current = [1_u8; INSTANCE_ID_LEN];
+ let next = [2_u8; INSTANCE_ID_LEN];
+ atomic_write(&state_dir.join("server-instance-id"), ¤t, 0o600).unwrap();
+ atomic_write(&state_dir.join("server-instance-id.next"), &next, 0o600).unwrap();
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 1,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let snapshot = PersistedSnapshot {
+ schema_version: SNAPSHOT_SCHEMA_VERSION,
+ instance_id: next,
+ generations: vec![Generation::FIRST; 1],
+ entries: Vec::new(),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ admin_replays: Vec::new(),
+ audit_records: VecDeque::new(),
+ root_epoch: 0,
+ };
+ write_snapshot_and_truncate_wal(&config, &admin_key, &snapshot).unwrap();
+ std::fs::write(state_dir.join("auth.wal"), b"old-instance-wal").unwrap();
+
+ let recovered = recover_instance_id_after_reset(&state_dir, &admin_key, current).unwrap();
+ assert_eq!(recovered, next);
+ assert_eq!(
+ read_instance_id_file(&state_dir.join("server-instance-id")).unwrap(),
+ Some(next)
+ );
+ assert!(!state_dir.join("server-instance-id.next").exists());
+ assert_eq!(std::fs::read(state_dir.join("auth.wal")).unwrap(), b"");
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[test]
+fn reset_already_installed_accepts_matching_live_id_and_snapshot() {
+ let state_dir = temp_state_dir("reset-already-installed");
+ prepare_state_dir(&state_dir).unwrap();
+ let admin_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let new_id = [9_u8; INSTANCE_ID_LEN];
+ atomic_write(&state_dir.join("server-instance-id"), &new_id, 0o600).unwrap();
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 1,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let snapshot = PersistedSnapshot {
+ schema_version: SNAPSHOT_SCHEMA_VERSION,
+ instance_id: new_id,
+ generations: vec![Generation::FIRST; 1],
+ entries: Vec::new(),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ admin_replays: Vec::new(),
+ audit_records: VecDeque::new(),
+ root_epoch: 1,
+ };
+ write_snapshot_and_truncate_wal(&config, &admin_key, &snapshot).unwrap();
+ assert!(reset_already_installed(&state_dir, &admin_key, &new_id));
+ assert!(!reset_already_installed(
+ &state_dir,
+ &admin_key,
+ &[8_u8; INSTANCE_ID_LEN]
+ ));
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[test]
+fn recover_instance_id_discards_stale_next_when_snapshot_still_matches_current() {
+ let state_dir = temp_state_dir("instance-next-stale");
+ prepare_state_dir(&state_dir).unwrap();
+ let admin_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let current = [3_u8; INSTANCE_ID_LEN];
+ let next = [4_u8; INSTANCE_ID_LEN];
+ atomic_write(&state_dir.join("server-instance-id"), ¤t, 0o600).unwrap();
+ atomic_write(&state_dir.join("server-instance-id.next"), &next, 0o600).unwrap();
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 1,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let snapshot = PersistedSnapshot {
+ schema_version: SNAPSHOT_SCHEMA_VERSION,
+ instance_id: current,
+ generations: vec![Generation::FIRST; 1],
+ entries: Vec::new(),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ admin_replays: Vec::new(),
+ audit_records: VecDeque::new(),
+ root_epoch: 0,
+ };
+ write_snapshot_and_truncate_wal(&config, &admin_key, &snapshot).unwrap();
+
+ let recovered = recover_instance_id_after_reset(&state_dir, &admin_key, current).unwrap();
+ assert_eq!(recovered, current);
+ assert!(!state_dir.join("server-instance-id.next").exists());
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[test]
+fn recover_admin_key_discards_leftover_wal_from_the_old_key() {
+ let state_dir = temp_state_dir("admin-next-wal");
+ prepare_state_dir(&state_dir).unwrap();
+ let old_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let new_key = *b"abcdefghijklmnopqrstuvwxyz012345";
+ let old_key_str = std::str::from_utf8(&old_key).unwrap();
+ let new_key_str = std::str::from_utf8(&new_key).unwrap();
+ write_admin_key(&state_dir, old_key_str).unwrap();
+ write_admin_key_file(&state_dir.join("admin.key.next"), new_key_str, true).unwrap();
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 1,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let snapshot = PersistedSnapshot {
+ schema_version: SNAPSHOT_SCHEMA_VERSION,
+ instance_id: [9_u8; INSTANCE_ID_LEN],
+ generations: vec![Generation::FIRST; 1],
+ entries: Vec::new(),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ admin_replays: Vec::new(),
+ audit_records: VecDeque::new(),
+ root_epoch: 0,
+ };
+ write_snapshot_and_truncate_wal(&config, &new_key, &snapshot).unwrap();
+ std::fs::write(state_dir.join("auth.wal"), b"old-key-wal").unwrap();
+
+ let recovered = recover_admin_key_after_rotation(&state_dir, old_key_str).unwrap();
+ assert_eq!(recovered.trim(), new_key_str);
+ assert_eq!(std::fs::read(state_dir.join("auth.wal")).unwrap(), b"");
+ assert!(!state_dir.join("admin.key.next").exists());
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[test]
+fn rotation_finalize_requires_the_live_admin_key() {
+ let state_dir = temp_state_dir("rotate-requires-live-key");
+ prepare_state_dir(&state_dir).unwrap();
+ let old_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let new_key = *b"abcdefghijklmnopqrstuvwxyz012345";
+ let old_key_str = std::str::from_utf8(&old_key).unwrap();
+ let new_key_str = std::str::from_utf8(&new_key).unwrap();
+ write_admin_key(&state_dir, old_key_str).unwrap();
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 1,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let snapshot = PersistedSnapshot {
+ schema_version: SNAPSHOT_SCHEMA_VERSION,
+ instance_id: [3_u8; INSTANCE_ID_LEN],
+ generations: vec![Generation::FIRST; 1],
+ entries: Vec::new(),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ admin_replays: Vec::new(),
+ audit_records: VecDeque::new(),
+ root_epoch: 1,
+ };
+ write_snapshot_and_truncate_wal(&config, &new_key, &snapshot).unwrap();
+ assert!(!rotation_already_installed(&state_dir, new_key_str));
+ write_admin_key(&state_dir, new_key_str).unwrap();
+ assert!(rotation_already_installed(&state_dir, new_key_str));
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[tokio::test]
+async fn interrupted_reset_recovers_the_staged_instance_id_on_restart() {
+ let state_dir = temp_state_dir("reset-recover");
+ let admin_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 4,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let runtime = AuthRuntime::start(admin_key, config.clone()).await.unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ let issued = runtime
+ .issue(
+ &admin,
+ Duration::from_secs(60),
+ Some("before-interrupted-reset".to_string()),
+ )
+ .await
+ .unwrap();
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+
+ let old_instance_id = load_or_create_instance_id(&state_dir).unwrap();
+ let next = random_instance_id();
+ atomic_write(&state_dir.join("server-instance-id.next"), &next, 0o600).unwrap();
+ let snapshot = PersistedSnapshot {
+ schema_version: SNAPSHOT_SCHEMA_VERSION,
+ instance_id: next,
+ generations: vec![Generation::FIRST; 4],
+ entries: Vec::new(),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ admin_replays: Vec::new(),
+ audit_records: VecDeque::new(),
+ root_epoch: 0,
+ };
+ write_snapshot_and_truncate_wal(&config, &admin_key, &snapshot).unwrap();
+ std::fs::write(state_dir.join("auth.wal"), b"old-instance-wal").unwrap();
+ atomic_write(
+ &state_dir.join("server-instance-id"),
+ &old_instance_id,
+ 0o600,
+ )
+ .unwrap();
+
+ let restored = AuthRuntime::start(admin_key, config).await.unwrap();
+ let restored_admin = authenticate_for_test(&restored, ADMIN_KEY_ID).unwrap();
+ let status = restored.status(&restored_admin).await.unwrap();
+ assert!(!status.safe_mode);
+ assert_eq!(status.server_instance_id, hex(&next));
+ assert!(authenticate_for_test(&restored, issued.metadata.key_id).is_err());
+ assert!(!state_dir.join("server-instance-id.next").exists());
+ drop(restored);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[tokio::test]
+async fn corrupt_wal_fails_temporary_keys_closed_until_admin_reset() {
+ let state_dir = temp_state_dir("auth-safe-mode");
+ let admin_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 4,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let runtime = AuthRuntime::start(admin_key, config.clone()).await.unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ let issued = runtime
+ .issue(
+ &admin,
+ Duration::from_secs(60),
+ Some("corrupt-me".to_string()),
+ )
+ .await
+ .unwrap();
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ std::fs::write(state_dir.join("auth.wal"), b"broken-wal").unwrap();
+
+ let recovered = AuthRuntime::start(admin_key, config).await.unwrap();
+ let recovered_admin = authenticate_for_test(&recovered, ADMIN_KEY_ID).unwrap();
+ assert!(recovered.status(&recovered_admin).await.unwrap().safe_mode);
+ assert_eq!(
+ authenticate_for_test(&recovered, issued.metadata.key_id)
+ .unwrap_err()
+ .code,
+ "temporary_key_store_unavailable"
+ );
+ recovered.reset(&recovered_admin).await.unwrap();
+ assert!(!recovered.status(&recovered_admin).await.unwrap().safe_mode);
+
+ drop(recovered);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[tokio::test]
+async fn root_rotation_rejects_old_key_and_in_flight_admin_context() {
+ let _process_credential_guard = PROCESS_CREDENTIAL_TEST_LOCK.lock().await;
+ let state_dir = temp_state_dir("auth-root-rotation");
+ let old_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let new_key = *b"abcdefghijklmnopqrstuvwxyz012345";
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 4,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let runtime = AuthRuntime::start(old_key, config).await.unwrap();
+ let old_admin = runtime
+ .authenticate_presented(ADMIN_KEY_ID, &old_key)
+ .unwrap();
+ let issued = runtime
+ .issue(
+ &old_admin,
+ Duration::from_secs(60),
+ Some("before-rotate".to_string()),
+ )
+ .await
+ .unwrap();
+ let old_temporary = runtime.derive_key(issued.metadata.key_id).unwrap();
+ let mut mistyped_temporary = old_temporary;
+ mistyped_temporary[0] ^= 0x01;
+ assert_eq!(
+ runtime
+ .authenticate_presented(issued.metadata.key_id, &mistyped_temporary)
+ .unwrap_err()
+ .code,
+ "temporary_key_invalid"
+ );
+ let mistyped_key = *b"1123456789abcdefghijklmnopqrstuv";
+ assert_eq!(
+ runtime
+ .authenticate_presented(ADMIN_KEY_ID, &mistyped_key)
+ .unwrap_err()
+ .code,
+ "administrator_key_invalid"
+ );
+
+ runtime
+ .rotate_root(&old_admin, new_key)
+ .await
+ .expect("root rotation should succeed");
+ assert_eq!(
+ runtime
+ .authenticate_presented(issued.metadata.key_id, &old_temporary)
+ .unwrap_err()
+ .code,
+ "temporary_key_rotated"
+ );
+ let new_admin = runtime
+ .authenticate_presented(ADMIN_KEY_ID, &new_key)
+ .unwrap();
+ let _replacement = runtime
+ .issue(
+ &new_admin,
+ Duration::from_secs(60),
+ Some("after-rotate".to_string()),
+ )
+ .await
+ .unwrap();
+ assert_eq!(
+ runtime
+ .authenticate_presented(issued.metadata.key_id, &old_temporary)
+ .unwrap_err()
+ .code,
+ "temporary_key_rotated"
+ );
+
+ assert_eq!(
+ runtime
+ .authenticate_presented(ADMIN_KEY_ID, &old_key)
+ .unwrap_err()
+ .code,
+ "administrator_key_invalid"
+ );
+ assert_eq!(
+ runtime
+ .issue(&old_admin, Duration::from_secs(60), None)
+ .await
+ .unwrap_err()
+ .code,
+ "administrator_key_rotated"
+ );
+ assert!(runtime.status(&new_admin).await.is_ok());
+
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[tokio::test]
+async fn admitted_admin_mutation_replay_survives_restart() {
+ let state_dir = temp_state_dir("admin-replay-restart");
+ let admin_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 4,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let fingerprint = [0x5a; 32];
+ let timestamp = unix_seconds();
+ let runtime = AuthRuntime::start(admin_key, config.clone()).await.unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ runtime
+ .claim_admin_mutation(&admin, fingerprint, timestamp)
+ .await
+ .unwrap();
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+
+ let restored = AuthRuntime::start(admin_key, config).await.unwrap();
+ let restored_admin = authenticate_for_test(&restored, ADMIN_KEY_ID).unwrap();
+ assert_eq!(
+ restored
+ .claim_admin_mutation(&restored_admin, fingerprint, timestamp)
+ .await
+ .unwrap_err()
+ .code,
+ "admin_request_replayed"
+ );
+
+ drop(restored);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[tokio::test]
+async fn snapshot_compaction_preserves_audit_records() {
+ let state_dir = temp_state_dir("audit-compaction");
+ let admin_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 4,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let runtime = AuthRuntime::start(admin_key, config.clone()).await.unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ runtime
+ .issue(&admin, Duration::from_secs(60), Some("audited".to_string()))
+ .await
+ .unwrap();
+ runtime.gc(&admin).await.unwrap();
+
+ let instance_id = load_or_create_instance_id(&state_dir).unwrap();
+ let persisted = try_load_persisted_state(&config, &admin_key, instance_id).unwrap();
+ let actions = persisted
+ .audit_records
+ .iter()
+ .map(|record| record.action.as_str())
+ .collect::>();
+ assert!(actions.contains(&"temporary_key_issue"));
+ assert!(actions.contains(&"temporary_key_gc"));
+
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[tokio::test]
+async fn revoking_keeps_the_row_until_its_retention_elapses() {
+ let state_dir = temp_state_dir("revoke-retention");
+ let admin_key = *b"0123456789abcdefghijklmnopqrstuv";
+ let config = AuthConfig {
+ state_dir: state_dir.clone(),
+ max_temporary_keys: 4,
+ max_temporary_key_ttl: Duration::from_secs(3600),
+ legacy_protocol: LegacyProtocolPolicy::Allow,
+ };
+ let runtime = AuthRuntime::start(admin_key, config).await.unwrap();
+ let admin = authenticate_for_test(&runtime, ADMIN_KEY_ID).unwrap();
+ let issued = runtime
+ .issue(&admin, Duration::from_secs(60), Some("revoked".to_string()))
+ .await
+ .unwrap();
+ let key_id = issued.metadata.key_id;
+ let presented = runtime.derive_key(key_id).unwrap();
+
+ runtime.revoke(&admin, key_id).await.unwrap();
+
+ // The credential stops working at once, but the row survives so the reason
+ // is still reportable rather than degrading to "unknown key".
+ assert_eq!(
+ runtime
+ .authenticate_presented(key_id, &presented)
+ .unwrap_err()
+ .code,
+ "temporary_key_revoked"
+ );
+ assert!(
+ runtime
+ .list(&admin, 0, 100)
+ .await
+ .unwrap()
+ .items
+ .iter()
+ .any(|item| item.key_id == key_id && item.state == "revoked")
+ );
+
+ drop(runtime);
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ let _ = std::fs::remove_dir_all(state_dir);
+}
+
+#[test]
+fn replay_pruning_removes_only_records_outside_the_retention_window() {
+ let now = 10_000;
+ let expired = AdminReplayRecord {
+ fingerprint: [1; 32],
+ client_timestamp: now - ADMIN_REPLAY_RETENTION.as_secs() - 1,
+ accepted_at: now - ADMIN_REPLAY_RETENTION.as_secs() - 1,
+ };
+ let current = AdminReplayRecord {
+ fingerprint: [2; 32],
+ client_timestamp: now,
+ accepted_at: now,
+ };
+ let mut replay_set = HashSet::from([expired.fingerprint, current.fingerprint]);
+ let mut replay_order = VecDeque::from([expired, current.clone()]);
+
+ super::actor::prune_expired_admin_replays(now, &mut replay_set, &mut replay_order);
+
+ assert_eq!(replay_set, HashSet::from([current.fingerprint]));
+ assert_eq!(replay_order.len(), 1);
+ assert_eq!(replay_order[0].fingerprint, current.fingerprint);
+}
+
+#[test]
+fn replay_pruning_uses_server_acceptance_not_client_timestamp() {
+ let now = 10_000;
+ let retention = ADMIN_REPLAY_RETENTION.as_secs();
+ let backdated = AdminReplayRecord {
+ fingerprint: [3; 32],
+ client_timestamp: now - retention - 1,
+ accepted_at: now - 1,
+ };
+ let future_dated_but_expired = AdminReplayRecord {
+ fingerprint: [4; 32],
+ client_timestamp: now + retention / 2,
+ accepted_at: now - retention - 1,
+ };
+ let mut replay_set =
+ HashSet::from([backdated.fingerprint, future_dated_but_expired.fingerprint]);
+ let mut replay_order = VecDeque::from([backdated.clone(), future_dated_but_expired]);
+
+ super::actor::prune_expired_admin_replays(now, &mut replay_set, &mut replay_order);
+
+ assert_eq!(replay_set, HashSet::from([backdated.fingerprint]));
+ assert_eq!(replay_order.len(), 1);
+ assert_eq!(replay_order[0].fingerprint, backdated.fingerprint);
+}
+
+#[test]
+fn replay_pruning_falls_back_to_client_timestamp_for_legacy_records() {
+ let now = 10_000;
+ let legacy_expired = AdminReplayRecord {
+ fingerprint: [5; 32],
+ client_timestamp: now - ADMIN_REPLAY_RETENTION.as_secs() - 1,
+ accepted_at: 0,
+ };
+ let legacy_current = AdminReplayRecord {
+ fingerprint: [6; 32],
+ client_timestamp: now,
+ accepted_at: 0,
+ };
+ let mut replay_set = HashSet::from([legacy_expired.fingerprint, legacy_current.fingerprint]);
+ let mut replay_order = VecDeque::from([legacy_expired, legacy_current.clone()]);
+
+ super::actor::prune_expired_admin_replays(now, &mut replay_set, &mut replay_order);
+
+ assert_eq!(replay_set, HashSet::from([legacy_current.fingerprint]));
+ assert_eq!(replay_order.len(), 1);
+ assert_eq!(replay_order[0].fingerprint, legacy_current.fingerprint);
+}
+
+#[test]
+fn tombstone_migration_prefers_audit_time_and_persists_fail_closed_fallback() {
+ let now = 10_000;
+ let revoked_with_audit = PersistedEntry {
+ key_id: KeyId::new(Generation::from_u32(1), SlotIndex::from_index(0)),
+ state: SlotState::Revoked,
+ issued_at: 100,
+ expires_at: 20_000,
+ label: None,
+ tombstoned_at: None,
+ };
+ let revoked_without_audit = PersistedEntry {
+ key_id: KeyId::new(Generation::from_u32(1), SlotIndex::from_index(1)),
+ state: SlotState::Revoked,
+ issued_at: 100,
+ expires_at: 20_000,
+ label: None,
+ tombstoned_at: None,
+ };
+ let audit_at = now - 30;
+ let mut snapshot = PersistedSnapshot {
+ schema_version: SNAPSHOT_SCHEMA_VERSION,
+ instance_id: [1; INSTANCE_ID_LEN],
+ generations: vec![Generation::from_u32(1), Generation::from_u32(1)],
+ entries: vec![revoked_with_audit, revoked_without_audit],
+ legacy_protocol: LegacyProtocolPolicy::Deny,
+ admin_replays: Vec::new(),
+ audit_records: VecDeque::from([AuditRecord {
+ at: audit_at,
+ action: "temporary_key_revoke".to_string(),
+ key_id: Some(KeyId::new(
+ Generation::from_u32(1),
+ SlotIndex::from_index(0),
+ )),
+ label: None,
+ }]),
+ root_epoch: 0,
+ };
+
+ assert!(normalize_tombstone_times(&mut snapshot, now));
+ assert_eq!(snapshot.entries[0].tombstoned_at, Some(audit_at));
+ assert_eq!(snapshot.entries[1].tombstoned_at, Some(now));
+ assert!(!normalize_tombstone_times(&mut snapshot, now + 1));
+ assert_eq!(snapshot.entries[1].tombstoned_at, Some(now));
+}
diff --git a/crates/pb-mapper-auth/src/timing_wheel.rs b/crates/pb-mapper-auth/src/timing_wheel.rs
new file mode 100644
index 0000000..aefcfb4
--- /dev/null
+++ b/crates/pb-mapper-auth/src/timing_wheel.rs
@@ -0,0 +1,444 @@
+//! Hierarchical timer wheel: rotating bucket queues indexed by relative delay.
+//!
+//! Levels are digit positions in base `radix`, and how many exist is derived from
+//! the longest delay the wheel must support. Scheduling decomposes the delay into
+//! those digits and builds one nested link per digit, coarsest outermost:
+//!
+//! ```text
+//! radix = 64, schedule(delay = 1*64² + 5*64 + 3)
+//!
+//! level 2 [ ][A][ ]… A pops after 1 rotation of 64² ticks; dropping it
+//! level 1 [ ]…[B][ ]… files B, which pops 5 rotations of 64 ticks later
+//! level 0 [ ][ ][C]… and files C, which pops 3 ticks later and fires
+//! ```
+//!
+//! `A` holds `B` holds `C` holds the timer, so the chain *is* the route: no per
+//! timer list of future placements, and nothing to look up or recompute. A bucket
+//! is just `Vec `, and dropping a link is what files the next one.
+//!
+//! ```text
+//! tick() -> ticks += 1
+//! -> level 0 always rotates; level i rotates when ticks % radix^i == 0
+//! -> rotate = pop_front, push_back an empty bucket; dropping the popped
+//! bucket files each link's successor, or fires the timer if the link
+//! was the innermost
+//! ```
+//!
+//! So a tick moves one bucket per level that turns over and performs no
+//! arithmetic per entry: the queues rotate, which keeps a bucket's index equal to
+//! its distance from now.
+//!
+//! Only the wheel holds strong references to a timer, so it runs when the last
+//! chain holding it is dropped. The wheel never looks a timer up, compares
+//! identities, or has to be told one was superseded: to move a deadline, schedule
+//! the same timer again — the earlier chain still drains, but it is no longer the
+//! last reference, so it fires nothing.
+
+use super::*;
+
+/// A callback that runs once: when its delay elapses, or when it is cancelled,
+/// whichever comes first.
+pub(super) struct Timer {
+ /// `None` once the callback has run, so any route still holding this timer is
+ /// inert and a cancelled timer cannot fire twice.
+ ///
+ /// WHY a lock for state a single task owns: running a `FnOnce` moves it out,
+ /// which needs `&mut`, but a timer is reached through a shared handle so that
+ /// two routes can hold one. `Arc: Send` — which `tokio::spawn` requires of
+ /// the actor this runs in — implies `T: Sync`, and shared mutability that is
+ /// `Sync` needs a lock; `Cell` would be cheaper but is not `Sync`. It is never
+ /// contended, and the path that fires almost every timer skips it: `Drop` has
+ /// `&mut self`, so it reaches the callback directly.
+ callback: Mutex>>,
+}
+
+impl Timer {
+ pub(super) fn new(callback: impl FnOnce() + Send + 'static) -> Arc {
+ Arc::new(Self {
+ callback: Mutex::new(Some(Box::new(callback))),
+ })
+ }
+
+ /// Runs the callback unless it has run already, for a caller cancelling ahead
+ /// of the deadline.
+ pub(super) fn fire(&self) {
+ let callback = self.callback.lock().take();
+ run(callback);
+ }
+}
+
+impl Drop for Timer {
+ /// Releasing the last reference is what fires a timer, so dropping the wheel
+ /// runs everything it was holding. Owning `&mut self` here is what lets the
+ /// usual path take the callback without locking.
+ fn drop(&mut self) {
+ let callback = self.callback.get_mut().take();
+ run(callback);
+ }
+}
+
+fn run(callback: Option>) {
+ if let Some(callback) = callback {
+ callback();
+ }
+}
+
+/// One leg of a timer's route through the levels.
+///
+/// A delay spanning several digits cannot be filed in one bucket, so the route is
+/// a chain: each [`Link::Relay`] waits in one bucket and, once that bucket comes
+/// off the front, hands the leg nested inside it to the wheel, which files it in
+/// the next, finer bucket. Only the outermost leg is ever in a bucket, and only
+/// [`Link::Deliver`] holds the timer, so the chain unwinding one bucket at a time
+/// *is* the timer descending the levels. That is what leaves a tick with nothing
+/// to compute.
+///
+/// ```text
+/// delay = 1*64² + 5*64 + 3
+/// Relay{L2,slot 1} -> Relay{L1,slot 5} -> Relay{L0,slot 3} -> Deliver(timer)
+/// ^ filed now ^ filed when the ^ …and so on ^ dropping this
+/// one before it fires the timer
+/// comes off
+/// ```
+enum Link {
+ /// The end of a route. Never read: holding the reference *is* this leg's job,
+ /// and releasing it is what fires the timer.
+ Deliver(#[allow(dead_code)] Arc),
+ /// Files `next` into `level`'s `slot` when this leg comes off the front.
+ ///
+ /// `Box`, not `Arc`: exactly one bucket owns a route at a time, so a leg needs
+ /// no reference count of its own — only the timer at the end is shared.
+ Relay {
+ level: u8,
+ slot: u16,
+ next: Box ,
+ },
+}
+
+/// A bucket's worth of routes. Dropping one without draining it releases the
+/// timers at the end of every route it holds, which is how dropping the wheel
+/// fires everything.
+type Bucket = Vec ;
+
+pub(super) struct TimingWheel {
+ /// Ticks elapsed since construction. Buckets are indexed relative to it, so
+ /// advancing re-indexes nothing.
+ ticks: u64,
+ radix: u64,
+ levels: Vec>,
+}
+
+impl TimingWheel {
+ /// Builds the smallest wheel that can place `max_delay` ticks, adding a level
+ /// at a time until the levels together span it.
+ pub(super) fn new(max_delay: u64, radix: u64) -> Self {
+ assert!(radix > 1, "a level needs at least two buckets");
+ let mut levels = 1_usize;
+ let mut span = radix;
+ while span < max_delay {
+ levels += 1;
+ span = span.saturating_mul(radix);
+ }
+ Self {
+ ticks: 0,
+ radix,
+ levels: (0..levels)
+ .map(|_| {
+ std::iter::repeat_with(Bucket::new)
+ .take(radix as usize)
+ .collect()
+ })
+ .collect(),
+ }
+ }
+
+ /// Longest delay this wheel can place exactly.
+ pub(super) fn max_delay(&self) -> u64 {
+ self.period(self.levels.len())
+ }
+
+ /// Holds `timer` for `delay` ticks. A delay of zero, or one past
+ /// [`Self::max_delay`], releases the timer at once rather than misplacing it.
+ ///
+ /// Scheduling a timer the wheel already holds builds a second route rather
+ /// than replacing the first, which is how a caller moves a deadline without
+ /// the wheel having to find the old one.
+ pub(super) fn schedule(&mut self, delay: u64, timer: Arc) {
+ if delay == 0 || delay > self.max_delay() {
+ // Dropping `timer` here fires it if this was the last reference.
+ return;
+ }
+ let deliver = Link::Deliver(timer);
+ // The coarsest reachable level absorbs however far the current tick sits
+ // into its rotation, so its bucket comes off on a rotation boundary. Every
+ // finer level is at zero offset there, which makes the delay still
+ // remaining a plain base-`radix` decomposition from that point down.
+ // The guard above bounds `delay`, so some level always takes it.
+ // Unreachable, and treated like the out-of-range case: dropping the
+ // timer fires it, which is the safe direction for a credential deadline.
+ let Some((level, slot, remaining)) = (0..self.levels.len())
+ .rev()
+ .find_map(|level| self.entry_leg(level, self.ticks + delay))
+ else {
+ return;
+ };
+ let route = match remaining {
+ 0 => deliver,
+ remaining => self.route(remaining, deliver),
+ };
+ self.file(level, slot, route);
+ }
+
+ /// Advances one tick, rotating every level that turns over.
+ pub(super) fn tick(&mut self) {
+ self.ticks += 1;
+ for level in 0..self.levels.len() {
+ // A level turns over every `radix^level` ticks. Once one does not, no
+ // coarser one can either, since its period divides theirs.
+ if !self.ticks.is_multiple_of(self.period(level)) {
+ break;
+ }
+ let bucket = self.rotate(level);
+ for link in bucket {
+ match link {
+ // Out of legs: dropping it releases the timer, firing it if
+ // this was the last route holding it.
+ Link::Deliver(timer) => drop(timer),
+ Link::Relay { level, slot, next } => {
+ self.file(level as usize, slot as usize, *next)
+ }
+ }
+ }
+ }
+ }
+
+ /// Takes `level`'s front bucket off and puts an empty one on the back, so
+ /// bucket indices stay relative to the current tick.
+ fn rotate(&mut self, level: usize) -> Bucket {
+ let queue = &mut self.levels[level];
+ let bucket = queue.pop_front().unwrap_or_default();
+ queue.push_back(Bucket::new());
+ bucket
+ }
+
+ fn file(&mut self, level: usize, slot: usize, link: Link) {
+ if let Some(bucket) = self.levels[level].get_mut(slot) {
+ bucket.push(link);
+ }
+ }
+
+ /// The route to file for a timer due `remaining` ticks after a rotation
+ /// boundary, built by recursing into the finer levels so each leg owns the
+ /// part of the route it hands on. `remaining` must be non-zero.
+ fn route(&self, remaining: u64, inner: Link) -> Link {
+ let (level, slot, rest) = self.next_leg(remaining);
+ let next = match rest {
+ 0 => inner,
+ rest => self.route(rest, inner),
+ };
+ Link::Relay {
+ level: level as u8,
+ slot: slot as u16,
+ next: Box::new(next),
+ }
+ }
+
+ /// The route's first leg if it starts at `level`: which bucket holds a timer
+ /// due at tick `target`, and how much delay that leaves for the legs after it.
+ /// `None` when this level's next rotation already overshoots `target`, or when
+ /// `target` is more than one revolution away.
+ fn entry_leg(&self, level: usize, target: u64) -> Option<(usize, usize, u64)> {
+ let period = self.period(level);
+ let next_rotation = self.ticks - self.ticks % period + period;
+ let ahead = target.checked_sub(next_rotation)?;
+ let slot = ahead / period;
+ (slot < self.radix).then_some((level, slot as usize, ahead % period))
+ }
+
+ /// The next leg for a timer due `remaining` ticks after a rotation boundary:
+ /// the coarsest level whose rotation still fits. From a boundary that level's
+ /// front bucket comes off one period out, so bucket `j` comes off after
+ /// `j + 1` of them.
+ fn next_leg(&self, remaining: u64) -> (usize, usize, u64) {
+ let level = (0..self.levels.len())
+ .rev()
+ .find(|level| self.period(*level) <= remaining)
+ .unwrap_or(0);
+ let period = self.period(level);
+ (level, (remaining / period - 1) as usize, remaining % period)
+ }
+
+ /// Ticks spanned by `level` and every level below it: `radix^level`.
+ fn period(&self, level: usize) -> u64 {
+ self.radix.saturating_pow(level as u32)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// A wheel plus the tick each scheduled timer actually fired on.
+ struct Harness {
+ wheel: TimingWheel,
+ fired: Arc>>,
+ ticks: Arc,
+ }
+
+ impl Harness {
+ fn new(max_delay: u64, radix: u64) -> Self {
+ Self {
+ wheel: TimingWheel::new(max_delay, radix),
+ fired: Arc::new(Mutex::new(Vec::new())),
+ ticks: Arc::new(AtomicU64::new(0)),
+ }
+ }
+
+ fn timer(&self, id: u32) -> Arc {
+ let fired = self.fired.clone();
+ let ticks = self.ticks.clone();
+ Timer::new(move || {
+ fired.lock().push((id, ticks.load(Ordering::Acquire)));
+ })
+ }
+
+ fn schedule(&mut self, id: u32, delay: u64) {
+ let timer = self.timer(id);
+ self.wheel.schedule(delay, timer);
+ }
+
+ fn tick(&mut self) {
+ self.ticks.fetch_add(1, Ordering::AcqRel);
+ self.wheel.tick();
+ }
+
+ fn fired_at(&self, id: u32) -> Option {
+ self.fired
+ .lock()
+ .iter()
+ .find(|(fired, _)| *fired == id)
+ .map(|(_, at)| *at)
+ }
+ }
+
+ /// Every delay, from every starting offset, must fire on exactly the tick it
+ /// asked for. This is the wheel's whole contract, and a radix decomposition is
+ /// easy to get wrong by one bucket, so it is checked exhaustively rather than
+ /// sampled.
+ #[test]
+ fn every_delay_fires_on_its_exact_tick() {
+ let radix = 4;
+ let max_delay = radix * radix * radix;
+ for offset in 0..2 * radix * radix {
+ let mut harness = Harness::new(max_delay, radix);
+ for _ in 0..offset {
+ harness.tick();
+ }
+ for delay in 1..=max_delay {
+ harness.schedule(delay as u32, delay);
+ }
+ for _ in 0..max_delay {
+ harness.tick();
+ }
+ for delay in 1..=max_delay {
+ assert_eq!(
+ harness.fired_at(delay as u32),
+ Some(offset + delay),
+ "radix {radix}, offset {offset}, delay {delay}"
+ );
+ }
+ }
+ }
+
+ /// The same contract at the shape the wheel actually runs with.
+ #[test]
+ fn every_short_delay_fires_on_its_exact_tick_at_radix_64() {
+ let radix = 64;
+ let mut harness = Harness::new(radix * radix * radix * radix, radix);
+ for _ in 0..100 {
+ harness.tick();
+ }
+ let delays = (1..=200).chain([radix - 1, radix, radix + 1, radix * radix, 4095, 4096]);
+ for delay in delays.clone() {
+ harness.schedule(delay as u32, delay);
+ }
+ for _ in 0..5000 {
+ harness.tick();
+ }
+ for delay in delays {
+ assert_eq!(
+ harness.fired_at(delay as u32),
+ Some(100 + delay),
+ "delay {delay}"
+ );
+ }
+ }
+
+ #[test]
+ fn level_count_covers_the_requested_delay() {
+ assert_eq!(TimingWheel::new(64, 64).max_delay(), 64);
+ assert_eq!(TimingWheel::new(65, 64).max_delay(), 4096);
+ assert_eq!(TimingWheel::new(4096, 64).max_delay(), 4096);
+ assert_eq!(TimingWheel::new(4097, 64).max_delay(), 262_144);
+ }
+
+ #[test]
+ fn a_delay_the_wheel_cannot_place_fires_at_once() {
+ let mut harness = Harness::new(64, 64);
+ harness.schedule(1, 65);
+ assert_eq!(harness.fired_at(1), Some(0));
+ harness.schedule(2, 0);
+ assert_eq!(harness.fired_at(2), Some(0));
+ }
+
+ #[test]
+ fn rescheduling_the_same_timer_defers_it_to_the_later_route() {
+ let mut harness = Harness::new(4096, 64);
+ let timer = harness.timer(1);
+ harness.wheel.schedule(5, timer.clone());
+ harness.wheel.schedule(20, timer);
+
+ for _ in 0..5 {
+ harness.tick();
+ }
+ assert_eq!(
+ harness.fired_at(1),
+ None,
+ "the earlier route must not fire the timer"
+ );
+ for _ in 5..20 {
+ harness.tick();
+ }
+ assert_eq!(harness.fired_at(1), Some(20));
+ }
+
+ #[test]
+ fn firing_early_makes_the_scheduled_route_inert() {
+ let mut harness = Harness::new(4096, 64);
+ let timer = harness.timer(1);
+ harness.wheel.schedule(10, timer.clone());
+
+ timer.fire();
+ assert_eq!(harness.fired_at(1), Some(0));
+ for _ in 0..10 {
+ harness.tick();
+ }
+ assert_eq!(harness.fired.lock().len(), 1);
+ }
+
+ #[test]
+ fn dropping_the_wheel_fires_everything_it_holds() {
+ let mut harness = Harness::new(262_144, 64);
+ harness.schedule(1, 5);
+ harness.schedule(2, 200_000);
+
+ let fired = harness.fired.clone();
+ drop(harness);
+ let ids = fired
+ .lock()
+ .iter()
+ .map(|(id, _)| *id)
+ .collect::>();
+ assert_eq!(ids, HashSet::from([1, 2]));
+ }
+}
diff --git a/crates/pb-mapper-cli/Cargo.toml b/crates/pb-mapper-cli/Cargo.toml
new file mode 100644
index 0000000..f45b297
--- /dev/null
+++ b/crates/pb-mapper-cli/Cargo.toml
@@ -0,0 +1,40 @@
+[package]
+name = "pb-mapper-cli"
+version.workspace = true
+edition.workspace = true
+authors.workspace = true
+
+# The binary keeps the name `pb-mapper`, discovered from
+# `src/bin/pb-mapper.rs`. Release workflows, both Dockerfiles, and the install
+# scripts all hardcode it, and `cargo build --bin pb-mapper` resolves it from
+# the workspace root regardless of which crate holds it.
+
+[dependencies]
+pb-mapper-auth.workspace = true
+pb-mapper-client.workspace = true
+pb-mapper-core.workspace = true
+pb-mapper-protocol.workspace = true
+pb-mapper-server.workspace = true
+
+better_mimalloc_rs.workspace = true
+clap.workspace = true
+serde_json.workspace = true
+tokio.workspace = true
+tokio-util.workspace = true
+tracing.workspace = true
+uni-stream.workspace = true
+
+[dev-dependencies]
+dotenvy.workspace = true
+rand.workspace = true
+
+[features]
+udp-timeout = [
+ "uni-stream/udp-timeout",
+ "pb-mapper-protocol/udp-timeout",
+ "pb-mapper-client/udp-timeout",
+ "pb-mapper-server/udp-timeout",
+]
+
+[lints]
+workspace = true
diff --git a/examples/echo_tcp_client.rs b/crates/pb-mapper-cli/examples/echo_tcp_client.rs
similarity index 100%
rename from examples/echo_tcp_client.rs
rename to crates/pb-mapper-cli/examples/echo_tcp_client.rs
diff --git a/examples/echo_tcp_server.rs b/crates/pb-mapper-cli/examples/echo_tcp_server.rs
similarity index 100%
rename from examples/echo_tcp_server.rs
rename to crates/pb-mapper-cli/examples/echo_tcp_server.rs
diff --git a/examples/echo_udp_client.rs b/crates/pb-mapper-cli/examples/echo_udp_client.rs
similarity index 93%
rename from examples/echo_udp_client.rs
rename to crates/pb-mapper-cli/examples/echo_udp_client.rs
index 1a69b4e..9db1a3a 100644
--- a/examples/echo_udp_client.rs
+++ b/crates/pb-mapper-cli/examples/echo_udp_client.rs
@@ -1,6 +1,6 @@
use std::error::Error;
-use pb_mapper::common::config::init_tracing;
+use pb_mapper_core::config::init_tracing;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use uni_stream::udp::UdpStream;
diff --git a/examples/echo_udp_server.rs b/crates/pb-mapper-cli/examples/echo_udp_server.rs
similarity index 88%
rename from examples/echo_udp_server.rs
rename to crates/pb-mapper-cli/examples/echo_udp_server.rs
index 135aae1..6dc17c5 100644
--- a/examples/echo_udp_server.rs
+++ b/crates/pb-mapper-cli/examples/echo_udp_server.rs
@@ -1,8 +1,11 @@
+// An example: panicking on a failed bind is the clearest thing it can do.
+#![allow(clippy::unwrap_used, clippy::expect_used)]
+
use std::error::Error;
use std::net::SocketAddr;
use std::str::FromStr;
-use pb_mapper::common::config::init_tracing;
+use pb_mapper_core::config::init_tracing;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use uni_stream::udp::UdpListener;
diff --git a/examples/pb_local_client.rs b/crates/pb-mapper-cli/examples/pb_local_client.rs
similarity index 72%
rename from examples/pb_local_client.rs
rename to crates/pb-mapper-cli/examples/pb_local_client.rs
index 3e7d1bc..e42731b 100644
--- a/examples/pb_local_client.rs
+++ b/crates/pb-mapper-cli/examples/pb_local_client.rs
@@ -1,5 +1,5 @@
-use pb_mapper::common::config::init_tracing;
-use pb_mapper::local::client::run_client_side_cli;
+use pb_mapper_client::client::run_client_side_cli;
+use pb_mapper_core::config::init_tracing;
use uni_stream::stream::TcpListenerProvider;
#[tokio::main]
diff --git a/examples/pb_local_server.rs b/crates/pb-mapper-cli/examples/pb_local_server.rs
similarity index 67%
rename from examples/pb_local_server.rs
rename to crates/pb-mapper-cli/examples/pb_local_server.rs
index 1ae9e97..584824d 100644
--- a/examples/pb_local_server.rs
+++ b/crates/pb-mapper-cli/examples/pb_local_server.rs
@@ -1,5 +1,5 @@
-use pb_mapper::common::config::init_tracing;
-use pb_mapper::local::server::{run_server_side_cli, ServerTunnelOptions};
+use pb_mapper_client::server::{ServerTunnelOptions, run_server_side_cli};
+use pb_mapper_core::config::init_tracing;
use uni_stream::stream::TcpStreamProvider;
#[tokio::main]
@@ -13,6 +13,8 @@ async fn main() {
need_codec: false,
is_datagram: false,
keep_alive: false,
+ namespace: None,
+ force_namespace: false,
},
)
.await;
diff --git a/examples/pb_server.rs b/crates/pb-mapper-cli/examples/pb_server.rs
similarity index 57%
rename from examples/pb_server.rs
rename to crates/pb-mapper-cli/examples/pb_server.rs
index 1c09ff0..b8e3ba5 100644
--- a/examples/pb_server.rs
+++ b/crates/pb-mapper-cli/examples/pb_server.rs
@@ -1,5 +1,5 @@
-use pb_mapper::common::config::init_tracing;
-use pb_mapper::pb_server::run_server;
+use pb_mapper_core::config::init_tracing;
+use pb_mapper_server::run_server;
#[tokio::main]
async fn main() -> std::io::Result<()> {
diff --git a/crates/pb-mapper-cli/src/bin/pb-mapper.rs b/crates/pb-mapper-cli/src/bin/pb-mapper.rs
new file mode 100644
index 0000000..14d6be5
--- /dev/null
+++ b/crates/pb-mapper-cli/src/bin/pb-mapper.rs
@@ -0,0 +1,641 @@
+//! Unified command-line entry point for every pb-mapper role.
+//!
+//! ```text
+//! +-> server (relay)
+//! process args -> clap -+-> register (publish a local service)
+//! +-> connect (open a local listener)
+//! +-> status (namespace-scoped inspection)
+//! +-> admin (credential/control plane)
+//! ```
+//!
+//! Role-specific execution stays below this dispatch layer. Administrator parsing,
+//! pagination, wire requests, and output rendering live in the `admin` module.
+
+use std::error::Error;
+use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
+use std::path::PathBuf;
+use std::time::Duration;
+
+use better_mimalloc_rs::MiMalloc;
+use clap::{Args, Parser, Subcommand, ValueEnum};
+use pb_mapper_auth::{
+ AuthConfig, KeyPage, LegacyProtocolPolicy, MAX_TEMP_KEY_CAPACITY, MAX_TEMP_KEY_TTL,
+ MIN_TEMP_KEY_TTL, acquire_state_dir_lock, generate_admin_key, initialize_admin_key,
+ write_admin_key_file,
+};
+use pb_mapper_client::client::{
+ handle_status_cli_scoped, run_client_side_cli_with_callback_scoped,
+};
+use pb_mapper_client::server::{ServerTunnelOptions, run_server_side_cli_with_pinned_credential};
+use pb_mapper_core::checksum::set_process_msg_header_key;
+use pb_mapper_core::checksum::{MACHINE_MSG_HEADER_KEY_PATH, setup_machine_msg_header_key};
+use pb_mapper_core::config::{
+ StatusOp, control_io_timeout, get_pb_mapper_server_async, get_sockaddr_async, init_tracing,
+ keep_alive_from_env,
+};
+use pb_mapper_protocol::MessageReader;
+use pb_mapper_protocol::command::{
+ AdminConnectionPage, AdminRequest, AdminResponse, AdminServicePage, MessageSerializer,
+ PbConnRequest, PbConnResponse,
+};
+use pb_mapper_protocol::forward::StreamForward;
+use pb_mapper_protocol::secure::ClientHeaderSession;
+use pb_mapper_server::run_server_with_shutdown;
+use tokio::net::TcpStream;
+use tokio_util::sync::CancellationToken;
+use uni_stream::stream::{
+ StreamProvider, TcpListenerProvider, TcpStreamProvider, UdpListenerProvider, UdpStreamProvider,
+};
+
+#[global_allocator]
+static GLOBAL_MIMALLOC: MiMalloc = MiMalloc;
+
+#[derive(Debug, Parser)]
+#[command(
+ author = "L_B__",
+ version,
+ about = "Expose and consume keyed TCP/UDP services through a pb-mapper relay",
+ subcommand_required = true,
+ arg_required_else_help = true
+)]
+struct Cli {
+ #[command(subcommand)]
+ command: Command,
+}
+
+#[derive(Debug, Subcommand)]
+enum Command {
+ /// Run the public relay server.
+ Server(ServerArgs),
+ /// Register a local service with a relay.
+ Register(RegisterArgs),
+ /// Expose a registered service on a local listening address.
+ Connect(ConnectArgs),
+ /// Query relay status.
+ Status(StatusArgs),
+ /// Manage temporary credentials and inspect relay authentication state.
+ Admin(AdminArgs),
+}
+
+#[derive(Debug, Args)]
+struct ServerArgs {
+ /// Port exposed to registering services and connecting clients.
+ #[arg(short, long, visible_alias = "pb-mapper-port", default_value_t = 7666)]
+ port: u16,
+ /// Listen on IPv6 (::) instead of IPv4 (0.0.0.0).
+ #[arg(long, visible_alias = "use-ipv6", default_value_t = false)]
+ ipv6: bool,
+ /// Enable TCP keep-alive. PB_MAPPER_KEEP_ALIVE=ON is also supported.
+ #[arg(long, default_value_t = false)]
+ keep_alive: bool,
+ /// Derive MSG_HEADER_KEY from this machine and persist it for other roles.
+ #[arg(long, default_value_t = false)]
+ use_machine_msg_header_key: bool,
+ /// Directory containing encrypted authentication state and the administrator key file.
+ /// Defaults to /var/lib/pb-mapper/auth for Linux services or a writable system
+ /// directory; otherwise a user-writable application directory.
+ #[arg(long)]
+ auth_state_dir: Option,
+ /// Create a random administrator key before starting the relay.
+ #[arg(
+ long,
+ conflicts_with = "use_machine_msg_header_key",
+ default_value_t = false
+ )]
+ init_admin_key: bool,
+ /// Replace an existing administrator key when used with --init-admin-key.
+ #[arg(long, requires = "init_admin_key", default_value_t = false)]
+ force_init_admin_key: bool,
+ /// Maximum temporary-key slots allocated by the relay.
+ #[arg(long)]
+ max_temporary_keys: Option,
+ /// Maximum accepted temporary-key TTL.
+ #[arg(long, value_parser = parse_duration)]
+ max_temporary_key_ttl: Option,
+ /// Allow or deny the legacy encrypted framing protocol.
+ #[arg(long, value_enum)]
+ legacy_protocol: Option,
+}
+
+#[path = "pb-mapper/admin.rs"]
+mod admin;
+use admin::AdminArgs;
+#[derive(Debug, Args)]
+struct RegisterArgs {
+ /// Transport used by the local service.
+ #[arg(value_enum)]
+ transport: Transport,
+ /// Service key registered with the relay.
+ #[arg(short, long)]
+ key: String,
+ /// Local service address to forward to.
+ #[arg(short, long, visible_alias = "local")]
+ addr: String,
+ #[command(flatten)]
+ relay: RelayArgs,
+ /// Encrypt forwarded traffic with the configured MSG_HEADER_KEY.
+ #[arg(short, long, default_value_t = false)]
+ codec: bool,
+ /// Administrator-only target namespace. Temporary credentials always use their own key id.
+ #[arg(long)]
+ namespace: Option,
+ /// Required when an administrator registers a service inside a temporary-key namespace.
+ #[arg(long, requires = "namespace", default_value_t = false)]
+ force: bool,
+}
+
+#[derive(Debug, Args)]
+struct ConnectArgs {
+ /// Transport exposed by the local listener.
+ #[arg(value_enum)]
+ transport: Transport,
+ /// Registered service key to subscribe to.
+ #[arg(short, long)]
+ key: String,
+ /// Local address on which downstream clients connect.
+ #[arg(short, long, visible_alias = "local")]
+ addr: String,
+ #[command(flatten)]
+ relay: RelayArgs,
+ /// Administrator-only target namespace. Temporary credentials always use their own key id.
+ #[arg(long)]
+ namespace: Option,
+}
+
+#[derive(Debug, Args)]
+struct StatusArgs {
+ /// Status query to execute.
+ #[arg(value_enum)]
+ op: StatusOp,
+ /// Relay address. Falls back to PB_MAPPER_SERVER.
+ #[arg(short, long, visible_alias = "pb-mapper-server", value_name = "ADDR")]
+ server: Option,
+ /// Administrator-only namespace to inspect.
+ #[arg(long)]
+ namespace: Option,
+}
+
+#[derive(Debug, Args)]
+struct RelayArgs {
+ /// Relay address. Falls back to PB_MAPPER_SERVER.
+ #[arg(short, long, visible_alias = "pb-mapper-server", value_name = "ADDR")]
+ server: Option,
+ /// Enable TCP keep-alive. PB_MAPPER_KEEP_ALIVE=ON is also supported.
+ #[arg(long, default_value_t = false)]
+ keep_alive: bool,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
+enum Transport {
+ Tcp,
+ Udp,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
+enum LegacyProtocolArg {
+ Allow,
+ Deny,
+}
+
+impl From for LegacyProtocolPolicy {
+ fn from(value: LegacyProtocolArg) -> Self {
+ match value {
+ LegacyProtocolArg::Allow => Self::Allow,
+ LegacyProtocolArg::Deny => Self::Deny,
+ }
+ }
+}
+
+#[tokio::main]
+async fn main() {
+ MiMalloc::init();
+ let cli = Cli::parse();
+ init_tracing();
+
+ if let Err(error) = run(cli).await {
+ tracing::error!(%error, "pb-mapper command failed");
+ std::process::exit(1);
+ }
+}
+
+async fn run(cli: Cli) -> Result<(), Box> {
+ match cli.command {
+ Command::Server(args) => run_server(args).await?,
+ Command::Register(args) => run_register(args).await?,
+ Command::Connect(args) => run_connect(args).await?,
+ Command::Status(args) => run_status(args).await?,
+ Command::Admin(args) => admin::run_admin(args).await?,
+ }
+ Ok(())
+}
+
+/// Publishes CLI flags as environment variables, which is how the auth
+/// subsystem reads its configuration.
+///
+/// # Safety note
+///
+/// Mutating the environment is unsafe in edition 2024 because it races
+/// concurrent readers. Every call here happens during argument handling on the
+/// main thread, before any runtime task or thread is spawned, so there is no
+/// concurrent reader to race.
+fn apply_server_auth_overrides(args: &ServerArgs) -> Result<(), Box> {
+ if let Some(auth_state_dir) = &args.auth_state_dir {
+ unsafe { std::env::set_var("PB_MAPPER_AUTH_STATE_DIR", auth_state_dir) };
+ }
+ if let Some(max_temporary_keys) = args.max_temporary_keys {
+ if !(1..=MAX_TEMP_KEY_CAPACITY).contains(&max_temporary_keys) {
+ return Err(format!(
+ "`--max-temporary-keys` must be between 1 and {MAX_TEMP_KEY_CAPACITY}"
+ )
+ .into());
+ }
+ unsafe {
+ std::env::set_var(
+ "PB_MAPPER_AUTH_MAX_TEMP_KEYS",
+ max_temporary_keys.to_string(),
+ )
+ };
+ }
+ if let Some(max_temporary_key_ttl) = args.max_temporary_key_ttl {
+ if max_temporary_key_ttl < MIN_TEMP_KEY_TTL || max_temporary_key_ttl > MAX_TEMP_KEY_TTL {
+ return Err(format!(
+ "`--max-temporary-key-ttl` must be between {}s and {}d",
+ MIN_TEMP_KEY_TTL.as_secs(),
+ MAX_TEMP_KEY_TTL.as_secs() / 86_400
+ )
+ .into());
+ }
+ unsafe {
+ std::env::set_var(
+ "PB_MAPPER_AUTH_MAX_TEMP_TTL_SECS",
+ max_temporary_key_ttl.as_secs().to_string(),
+ )
+ };
+ }
+ Ok(())
+}
+
+async fn run_server(args: ServerArgs) -> Result<(), Box> {
+ apply_server_auth_overrides(&args)?;
+ if let Some(legacy_protocol) = args.legacy_protocol {
+ // SAFETY: as in `apply_server_auth_overrides` — this runs before the
+ // server spawns anything that reads the environment.
+ unsafe {
+ std::env::set_var(
+ "PB_MAPPER_LEGACY_PROTOCOL",
+ match legacy_protocol {
+ LegacyProtocolArg::Allow => "allow",
+ LegacyProtocolArg::Deny => "deny",
+ },
+ )
+ };
+ }
+ let auth_config = AuthConfig::default();
+ if args.init_admin_key {
+ std::fs::create_dir_all(&auth_config.state_dir)?;
+ let _lock = acquire_state_dir_lock(&auth_config.state_dir)?;
+ let key_path = auth_config.state_dir.join("admin.key");
+ let key = initialize_admin_key(&key_path, args.force_init_admin_key)?;
+ drop(_lock);
+ set_process_msg_header_key(Some(&key))?;
+ eprintln!("administrator key initialized at {}", key_path.display());
+ } else if args.use_machine_msg_header_key {
+ let admin_key_path = auth_config.state_dir.join("admin.key");
+ if admin_key_path.exists() {
+ return Err(format!(
+ "--use-machine-msg-header-key cannot replace `{}`; use `pb-mapper admin root-key rotate` to change the root key",
+ admin_key_path.display()
+ )
+ .into());
+ }
+ tracing::warn!(
+ "--use-machine-msg-header-key is a legacy compatibility option; prefer a random administrator key"
+ );
+ setup_machine_msg_header_key()?;
+ tracing::info!(
+ path = MACHINE_MSG_HEADER_KEY_PATH,
+ "derived and persisted machine MSG_HEADER_KEY"
+ );
+ }
+
+ let ip_addr = if args.ipv6 {
+ IpAddr::V6(Ipv6Addr::UNSPECIFIED)
+ } else {
+ IpAddr::V4(Ipv4Addr::UNSPECIFIED)
+ };
+ run_server_with_shutdown(
+ (ip_addr, args.port),
+ CancellationToken::new(),
+ None,
+ args.keep_alive || keep_alive_from_env(),
+ )
+ .await?;
+ Ok(())
+}
+
+async fn run_register(args: RegisterArgs) -> Result<(), Box> {
+ let credential = pb_mapper_core::checksum::get_process_credential().map_err(|error| {
+ std::io::Error::other(format!("registration credential is required: {error}"))
+ })?;
+ let local_addr = get_sockaddr_async(&args.addr).await?;
+ let remote_addr = get_pb_mapper_server_async(args.relay.server.as_deref()).await?;
+ let options = ServerTunnelOptions {
+ need_codec: args.codec,
+ is_datagram: args.transport == Transport::Udp,
+ keep_alive: args.relay.keep_alive || keep_alive_from_env(),
+ namespace: args.namespace,
+ force_namespace: args.force,
+ };
+
+ match args.transport {
+ Transport::Tcp => {
+ register::(local_addr, remote_addr, args.key, options, credential)
+ .await
+ }
+ Transport::Udp => {
+ register::(local_addr, remote_addr, args.key, options, credential)
+ .await
+ }
+ }
+ Ok(())
+}
+
+async fn register(
+ local_addr: std::net::SocketAddr,
+ remote_addr: std::net::SocketAddr,
+ key: String,
+ options: ServerTunnelOptions,
+ credential: pb_mapper_core::checksum::Credential,
+) where
+ LocalStream::Item: StreamForward,
+{
+ run_server_side_cli_with_pinned_credential::(
+ local_addr,
+ remote_addr,
+ key.into(),
+ options,
+ None,
+ credential,
+ )
+ .await;
+}
+
+async fn run_connect(args: ConnectArgs) -> Result<(), Box> {
+ let credential = pb_mapper_core::checksum::get_process_credential().map_err(|error| {
+ std::io::Error::other(format!("client credential is required: {error}"))
+ })?;
+ let local_addr = get_sockaddr_async(&args.addr).await?;
+ let remote_addr = get_pb_mapper_server_async(args.relay.server.as_deref()).await?;
+ let key = args.key.into();
+ let keep_alive = args.relay.keep_alive || keep_alive_from_env();
+
+ match args.transport {
+ Transport::Tcp => {
+ run_client_side_cli_with_callback_scoped::(
+ local_addr,
+ remote_addr,
+ key,
+ keep_alive,
+ args.namespace,
+ None,
+ Some(credential),
+ )
+ .await;
+ }
+ Transport::Udp => {
+ run_client_side_cli_with_callback_scoped::(
+ local_addr,
+ remote_addr,
+ key,
+ keep_alive,
+ args.namespace,
+ None,
+ Some(credential),
+ )
+ .await;
+ }
+ }
+ Ok(())
+}
+
+async fn run_status(args: StatusArgs) -> Result<(), Box> {
+ let remote_addr = get_pb_mapper_server_async(args.server.as_deref()).await?;
+ handle_status_cli_scoped(args.op, remote_addr, args.namespace).await
+}
+
+fn parse_duration(raw: &str) -> Result {
+ let raw = raw.trim();
+ if raw.is_empty() {
+ return Err("duration must not be empty".to_string());
+ }
+ let split = raw
+ .find(|character: char| !character.is_ascii_digit())
+ .unwrap_or(raw.len());
+ let (number, unit) = raw.split_at(split);
+ let value = number
+ .parse::()
+ .map_err(|_| format!("invalid duration `{raw}`"))?;
+ let multiplier = match unit {
+ "" | "s" => 1,
+ "m" => 60,
+ "h" => 60 * 60,
+ "d" => 24 * 60 * 60,
+ _ => {
+ return Err(format!(
+ "unsupported duration unit `{unit}`; use s, m, h, or d"
+ ));
+ }
+ };
+ value
+ .checked_mul(multiplier)
+ .map(Duration::from_secs)
+ .ok_or_else(|| "duration is too large".to_string())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_each_runtime_role() {
+ let cases = [
+ vec!["pb-mapper", "server", "--port", "7666", "--ipv6"],
+ vec![
+ "pb-mapper",
+ "register",
+ "tcp",
+ "--key",
+ "web",
+ "--addr",
+ "127.0.0.1:8080",
+ "--server",
+ "relay:7666",
+ "--codec",
+ ],
+ vec![
+ "pb-mapper",
+ "connect",
+ "udp",
+ "--key",
+ "game",
+ "--addr",
+ "127.0.0.1:8211",
+ "--server",
+ "relay:7666",
+ ],
+ vec!["pb-mapper", "status", "keys", "--server", "relay:7666"],
+ vec![
+ "pb-mapper",
+ "admin",
+ "--server",
+ "relay:7666",
+ "key",
+ "issue",
+ "--ttl",
+ "30d",
+ "--label",
+ "build-agent",
+ ],
+ vec![
+ "pb-mapper",
+ "admin",
+ "--output",
+ "ndjson",
+ "connection",
+ "list",
+ "--page-size",
+ "1000",
+ "--all",
+ ],
+ vec![
+ "pb-mapper",
+ "admin",
+ "root-key",
+ "rotate",
+ "--key-file",
+ "/tmp/pb-mapper-admin.key",
+ ],
+ ];
+
+ for args in cases {
+ Cli::try_parse_from(args).expect("unified command should parse");
+ }
+ }
+
+ #[test]
+ fn accepts_documented_option_aliases() {
+ Cli::try_parse_from([
+ "pb-mapper",
+ "server",
+ "--pb-mapper-port",
+ "7666",
+ "--use-ipv6",
+ ])
+ .expect("server aliases should parse");
+ Cli::try_parse_from([
+ "pb-mapper",
+ "register",
+ "tcp",
+ "--key",
+ "web",
+ "--local",
+ "127.0.0.1:8080",
+ "--pb-mapper-server",
+ "relay:7666",
+ ])
+ .expect("relay and local aliases should parse");
+ Cli::try_parse_from([
+ "pb-mapper",
+ "register",
+ "tcp",
+ "--key",
+ "web",
+ "--addr",
+ "127.0.0.1:8080",
+ "--namespace",
+ "4294967296",
+ "--force",
+ ])
+ .expect("administrator namespace registration flags should parse");
+ }
+
+ #[test]
+ fn rejects_invalid_admin_paging_and_duration() {
+ assert!(
+ Cli::try_parse_from(["pb-mapper", "admin", "key", "list", "--page-size", "1001",])
+ .is_err()
+ );
+ assert!(
+ Cli::try_parse_from(["pb-mapper", "admin", "key", "issue", "--ttl", "1fortnight",])
+ .is_err()
+ );
+ assert!(
+ Cli::try_parse_from([
+ "pb-mapper",
+ "server",
+ "--init-admin-key",
+ "--use-machine-msg-header-key",
+ ])
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn server_auth_options_only_override_environment_when_explicit() {
+ let cli =
+ Cli::try_parse_from(["pb-mapper", "server"]).expect("server defaults should parse");
+ let Command::Server(defaults) = cli.command else {
+ panic!("expected server command");
+ };
+ assert_eq!(defaults.auth_state_dir, None);
+ assert_eq!(defaults.max_temporary_keys, None);
+ assert_eq!(defaults.max_temporary_key_ttl, None);
+ assert_eq!(defaults.legacy_protocol, None);
+
+ let cli = Cli::try_parse_from([
+ "pb-mapper",
+ "server",
+ "--auth-state-dir",
+ "/tmp/pb-mapper-auth",
+ "--max-temporary-keys",
+ "1024",
+ "--max-temporary-key-ttl",
+ "2h",
+ "--legacy-protocol",
+ "deny",
+ ])
+ .expect("explicit server authentication options should parse");
+ let Command::Server(explicit) = cli.command else {
+ panic!("expected server command");
+ };
+ assert_eq!(
+ explicit.auth_state_dir,
+ Some(PathBuf::from("/tmp/pb-mapper-auth"))
+ );
+ assert_eq!(explicit.max_temporary_keys, Some(1024));
+ assert_eq!(
+ explicit.max_temporary_key_ttl,
+ Some(Duration::from_secs(2 * 60 * 60))
+ );
+ assert_eq!(explicit.legacy_protocol, Some(LegacyProtocolArg::Deny));
+ }
+
+ #[test]
+ fn explicit_out_of_range_server_auth_flags_are_rejected() {
+ let cli = Cli::try_parse_from(["pb-mapper", "server", "--max-temporary-keys", "0"])
+ .expect("clap should accept the token before bounds checking");
+ let Command::Server(args) = cli.command else {
+ panic!("expected server command");
+ };
+ let error = apply_server_auth_overrides(&args).unwrap_err();
+ assert!(error.to_string().contains("--max-temporary-keys"));
+
+ let cli = Cli::try_parse_from(["pb-mapper", "server", "--max-temporary-key-ttl", "5s"])
+ .expect("clap should accept the token before bounds checking");
+ let Command::Server(args) = cli.command else {
+ panic!("expected server command");
+ };
+ let error = apply_server_auth_overrides(&args).unwrap_err();
+ assert!(error.to_string().contains("--max-temporary-key-ttl"));
+ }
+}
diff --git a/crates/pb-mapper-cli/src/bin/pb-mapper/admin.rs b/crates/pb-mapper-cli/src/bin/pb-mapper/admin.rs
new file mode 100644
index 0000000..1f9d620
--- /dev/null
+++ b/crates/pb-mapper-cli/src/bin/pb-mapper/admin.rs
@@ -0,0 +1,685 @@
+//! Administrator CLI: command parsing, one-shot V2 requests, pagination, and rendering.
+//!
+//! ```text
+//! admin args -> AdminRequest -> authenticated V2 connection -> relay
+//! ^ |
+//! +--- human / JSON / NDJSON <- AdminResponse <--------+
+//! ```
+//!
+//! `--all` keeps the selected output contract: human and JSON aggregate pages,
+//! while NDJSON deliberately streams one item at a time. Root-key rotation stages
+//! a recovery copy before contacting the relay, then verifies the new credential.
+
+use super::*;
+
+#[derive(Debug, Args)]
+pub(super) struct AdminArgs {
+ /// Relay address. Falls back to PB_MAPPER_SERVER.
+ #[arg(short, long, visible_alias = "pb-mapper-server", value_name = "ADDR")]
+ server: Option,
+ /// Machine-readable output mode.
+ #[arg(long, value_enum, default_value_t = OutputFormat::Human)]
+ output: OutputFormat,
+ #[command(subcommand)]
+ command: AdminCommand,
+}
+
+#[derive(Debug, Subcommand)]
+enum AdminCommand {
+ /// Issue, inspect, renew, reveal, revoke, or collect temporary keys.
+ Key(AdminKeyArgs),
+ /// List relay connections across namespaces.
+ Connection(AdminConnectionArgs),
+ /// List registered services across namespaces.
+ Service(AdminServiceArgs),
+ /// Show authentication state and protocol counters.
+ Status,
+ /// Repair or reset encrypted temporary-key state.
+ AuthState(AdminAuthStateArgs),
+ /// Rotate the sole administrator key and invalidate every existing credential.
+ RootKey(AdminRootKeyArgs),
+ /// Change legacy protocol acceptance at runtime.
+ LegacyProtocol(AdminLegacyProtocolArgs),
+}
+
+#[derive(Debug, Args)]
+struct AdminKeyArgs {
+ #[command(subcommand)]
+ command: AdminKeyCommand,
+}
+
+#[derive(Debug, Subcommand)]
+enum AdminKeyCommand {
+ Issue {
+ #[arg(long, value_parser = parse_duration)]
+ ttl: Duration,
+ #[arg(long)]
+ label: Option,
+ },
+ List {
+ #[arg(long, default_value_t = 0)]
+ page: u32,
+ #[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u16).range(1..=1000))]
+ page_size: u16,
+ #[arg(long, default_value_t = false)]
+ all: bool,
+ },
+ Show {
+ key_id: u64,
+ },
+ Reveal {
+ key_id: u64,
+ },
+ Renew {
+ key_id: u64,
+ #[arg(long, value_parser = parse_duration)]
+ ttl: Duration,
+ },
+ Revoke {
+ key_id: u64,
+ },
+ Gc,
+}
+
+#[derive(Debug, Args)]
+struct AdminConnectionArgs {
+ #[command(subcommand)]
+ command: AdminListCommand,
+}
+
+#[derive(Debug, Args)]
+struct AdminServiceArgs {
+ #[command(subcommand)]
+ command: AdminListCommand,
+}
+
+#[derive(Debug, Clone, Subcommand)]
+enum AdminListCommand {
+ List {
+ #[arg(long)]
+ key_id: Option,
+ #[arg(long, default_value_t = 0)]
+ page: u32,
+ #[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u16).range(1..=1000))]
+ page_size: u16,
+ #[arg(long, default_value_t = false)]
+ all: bool,
+ },
+}
+
+#[derive(Debug, Args)]
+struct AdminAuthStateArgs {
+ #[command(subcommand)]
+ command: AdminAuthStateCommand,
+}
+
+#[derive(Debug, Subcommand)]
+enum AdminAuthStateCommand {
+ Reset {
+ #[arg(long, default_value_t = false)]
+ confirm: bool,
+ },
+}
+
+#[derive(Debug, Args)]
+struct AdminRootKeyArgs {
+ #[command(subcommand)]
+ command: AdminRootKeyCommand,
+}
+
+#[derive(Debug, Subcommand)]
+enum AdminRootKeyCommand {
+ Rotate {
+ /// New 32-byte administrator key. A cryptographically random printable key is generated when omitted.
+ #[arg(long)]
+ new_key: Option,
+ /// Save the new key here before asking the relay to rotate.
+ #[arg(long)]
+ key_file: Option,
+ },
+}
+
+#[derive(Debug, Args)]
+struct AdminLegacyProtocolArgs {
+ #[command(subcommand)]
+ command: AdminLegacyProtocolCommand,
+}
+
+#[derive(Debug, Subcommand)]
+enum AdminLegacyProtocolCommand {
+ Set { policy: LegacyProtocolArg },
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
+enum OutputFormat {
+ Human,
+ Json,
+ Ndjson,
+}
+
+pub(super) async fn run_admin(args: AdminArgs) -> Result<(), Box> {
+ let remote_addr = get_pb_mapper_server_async(args.server.as_deref()).await?;
+ match args.command {
+ AdminCommand::Key(AdminKeyArgs { command }) => match command {
+ AdminKeyCommand::Issue { ttl, label } => {
+ let response = send_admin_request(
+ remote_addr,
+ AdminRequest::KeyIssue {
+ ttl_seconds: ttl.as_secs(),
+ label,
+ },
+ )
+ .await?;
+ print_admin_response(args.output, &response)?;
+ }
+ AdminKeyCommand::List {
+ page,
+ page_size,
+ all,
+ } => {
+ stream_key_pages(remote_addr, args.output, page, page_size, all).await?;
+ }
+ AdminKeyCommand::Show { key_id } => {
+ let response =
+ send_admin_request(remote_addr, AdminRequest::KeyShow { key_id }).await?;
+ print_admin_response(args.output, &response)?;
+ }
+ AdminKeyCommand::Reveal { key_id } => {
+ let response =
+ send_admin_request(remote_addr, AdminRequest::KeyReveal { key_id }).await?;
+ print_admin_response(args.output, &response)?;
+ }
+ AdminKeyCommand::Renew { key_id, ttl } => {
+ let response = send_admin_request(
+ remote_addr,
+ AdminRequest::KeyRenew {
+ key_id,
+ ttl_seconds: ttl.as_secs(),
+ },
+ )
+ .await?;
+ print_admin_response(args.output, &response)?;
+ }
+ AdminKeyCommand::Revoke { key_id } => {
+ let response =
+ send_admin_request(remote_addr, AdminRequest::KeyRevoke { key_id }).await?;
+ print_admin_response(args.output, &response)?;
+ }
+ AdminKeyCommand::Gc => {
+ let response = send_admin_request(remote_addr, AdminRequest::KeyGc).await?;
+ print_admin_response(args.output, &response)?;
+ }
+ },
+ AdminCommand::Connection(AdminConnectionArgs { command }) => {
+ let AdminListCommand::List {
+ key_id,
+ page,
+ page_size,
+ all,
+ } = command;
+ stream_connection_pages(remote_addr, args.output, key_id, page, page_size, all).await?;
+ }
+ AdminCommand::Service(AdminServiceArgs { command }) => {
+ let AdminListCommand::List {
+ key_id,
+ page,
+ page_size,
+ all,
+ } = command;
+ stream_service_pages(remote_addr, args.output, key_id, page, page_size, all).await?;
+ }
+ AdminCommand::Status => {
+ let response = send_admin_request(remote_addr, AdminRequest::AuthStatus).await?;
+ print_admin_response(args.output, &response)?;
+ }
+ AdminCommand::AuthState(AdminAuthStateArgs {
+ command: AdminAuthStateCommand::Reset { confirm },
+ }) => {
+ let response =
+ send_admin_request(remote_addr, AdminRequest::AuthStateReset { confirm }).await?;
+ print_admin_response(args.output, &response)?;
+ }
+ AdminCommand::RootKey(AdminRootKeyArgs {
+ command: AdminRootKeyCommand::Rotate { new_key, key_file },
+ }) => {
+ let key_file = key_file.unwrap_or_else(default_admin_recovery_key_file);
+ let new_key = new_key.unwrap_or_else(generate_admin_key);
+ let staged_key_file = key_file.with_file_name(format!(
+ ".{}.next",
+ key_file
+ .file_name()
+ .and_then(|name| name.to_str())
+ .unwrap_or("admin.key")
+ ));
+ write_admin_key_file(&staged_key_file, &new_key, true)?;
+ let response = send_admin_request(
+ remote_addr,
+ AdminRequest::RootKeyRotate {
+ new_admin_key: new_key.clone(),
+ },
+ )
+ .await
+ .map_err(|error| {
+ std::io::Error::other(format!(
+ "root rotation request failed; the candidate key remains at `{}`: {error}",
+ staged_key_file.display()
+ ))
+ })?;
+ set_process_msg_header_key(Some(&new_key))?;
+ let verification = send_admin_request(remote_addr, AdminRequest::AuthStatus).await?;
+ if !matches!(verification, AdminResponse::AuthStatus(_)) {
+ return Err(std::io::Error::other(
+ "new administrator key did not pass the post-rotation status check",
+ )
+ .into());
+ }
+ write_admin_key_file(&key_file, &new_key, true).map_err(|error| {
+ std::io::Error::other(format!(
+ "administrator key rotated and verified, but `{}` could not be updated; recover the key from `{}`: {error}",
+ key_file.display(),
+ staged_key_file.display()
+ ))
+ })?;
+ if let Err(error) = std::fs::remove_file(&staged_key_file) {
+ tracing::warn!(
+ path = %staged_key_file.display(),
+ %error,
+ "administrator key was rotated, but the staged key file could not be removed"
+ );
+ }
+ if args.output == OutputFormat::Human {
+ println!("administrator key rotated and verified");
+ println!("all temporary credentials are now invalid (temporary_key_rotated)");
+ println!("key file: {}", key_file.display());
+ } else {
+ print_admin_response(args.output, &response)?;
+ }
+ }
+ AdminCommand::LegacyProtocol(AdminLegacyProtocolArgs {
+ command: AdminLegacyProtocolCommand::Set { policy },
+ }) => {
+ let response = send_admin_request(
+ remote_addr,
+ AdminRequest::LegacyProtocolSet {
+ policy: policy.into(),
+ },
+ )
+ .await?;
+ print_admin_response(args.output, &response)?;
+ }
+ }
+ Ok(())
+}
+
+async fn send_admin_request(
+ remote_addr: std::net::SocketAddr,
+ request: AdminRequest,
+) -> Result> {
+ send_admin_request_with_timeout(remote_addr, request, control_io_timeout()).await
+}
+
+async fn send_admin_request_with_timeout(
+ remote_addr: std::net::SocketAddr,
+ request: AdminRequest,
+ io_timeout: Duration,
+) -> Result> {
+ let encoded = PbConnRequest::Admin(request).encode()?;
+ for attempt in 0..2 {
+ let sent = std::sync::atomic::AtomicBool::new(false);
+ let attempt_result = tokio::time::timeout(io_timeout, async {
+ let mut stream = TcpStream::connect(remote_addr)
+ .await
+ .map_err(|error| -> Box { Box::new(error) })?;
+ let session = ClientHeaderSession::from_process()?;
+ session.write_initial(&mut stream, &encoded).await?;
+ sent.store(true, std::sync::atomic::Ordering::Release);
+ let mut reader = session.response_reader(&mut stream)?;
+ let message = reader.read_msg().await?;
+ Ok::<_, Box>(PbConnResponse::decode(message)?)
+ })
+ .await
+ .map_err(|_| {
+ std::io::Error::new(
+ std::io::ErrorKind::TimedOut,
+ format!(
+ "administrator request attempt timed out after {} ms",
+ io_timeout.as_millis()
+ ),
+ )
+ });
+ let pre_send = !sent.load(std::sync::atomic::Ordering::Acquire);
+ let response = match attempt_result {
+ Ok(Ok(response)) => response,
+ Ok(Err(_)) if attempt == 0 && pre_send => continue,
+ Ok(Err(error)) => return Err(error),
+ Err(_) if attempt == 0 && pre_send => continue,
+ Err(error) => return Err(error.into()),
+ };
+ match response {
+ PbConnResponse::Admin(response) => return Ok(response),
+ PbConnResponse::Error(error)
+ if error.code == "connection_salt_replayed" && error.retryable =>
+ {
+ if attempt == 0 {
+ continue;
+ }
+ }
+ PbConnResponse::Error(error) => {
+ return Err(std::io::Error::other(format!(
+ "{}: {} (retryable={})",
+ error.code, error.message, error.retryable
+ ))
+ .into());
+ }
+ response => {
+ return Err(std::io::Error::other(format!(
+ "unexpected administrator response: {response:?}"
+ ))
+ .into());
+ }
+ }
+ }
+ Err(std::io::Error::other("connection salt replay retry was exhausted").into())
+}
+
+async fn stream_key_pages(
+ remote_addr: std::net::SocketAddr,
+ output: OutputFormat,
+ mut page: u32,
+ page_size: u16,
+ all: bool,
+) -> Result<(), Box> {
+ let mut combined: Option = None;
+ loop {
+ let response =
+ send_admin_request(remote_addr, AdminRequest::KeyList { page, page_size }).await?;
+ let AdminResponse::KeyList(key_page) = &response else {
+ return Err(std::io::Error::other("unexpected key-list response").into());
+ };
+ if all {
+ if output == OutputFormat::Ndjson {
+ for item in &key_page.items {
+ println!("{}", serde_json::to_string(item)?);
+ }
+ } else {
+ let page = combined.get_or_insert_with(|| {
+ let mut page = key_page.clone();
+ page.items.clear();
+ page.next_page = None;
+ page
+ });
+ page.items.extend(key_page.items.iter().cloned());
+ }
+ } else {
+ print_admin_response(output, &response)?;
+ }
+ let Some(next_page) = key_page.next_page else {
+ break;
+ };
+ if !all {
+ break;
+ }
+ page = next_page;
+ }
+ if let Some(page) = combined {
+ print_admin_response(output, &AdminResponse::KeyList(page))?;
+ }
+ Ok(())
+}
+
+async fn stream_service_pages(
+ remote_addr: std::net::SocketAddr,
+ output: OutputFormat,
+ key_id: Option,
+ mut page: u32,
+ page_size: u16,
+ all: bool,
+) -> Result<(), Box> {
+ let mut combined: Option = None;
+ loop {
+ let response = send_admin_request(
+ remote_addr,
+ AdminRequest::ServiceList {
+ key_id,
+ page,
+ page_size,
+ },
+ )
+ .await?;
+ let AdminResponse::Services(service_page) = &response else {
+ return Err(std::io::Error::other("unexpected service-list response").into());
+ };
+ if all {
+ if output == OutputFormat::Ndjson {
+ for item in &service_page.items {
+ println!("{}", serde_json::to_string(item)?);
+ }
+ } else {
+ let page = combined.get_or_insert_with(|| {
+ let mut page = service_page.clone();
+ page.items.clear();
+ page.next_page = None;
+ page
+ });
+ page.items.extend(service_page.items.iter().cloned());
+ }
+ } else {
+ print_admin_response(output, &response)?;
+ }
+ let Some(next_page) = service_page.next_page else {
+ break;
+ };
+ if !all {
+ break;
+ }
+ page = next_page;
+ }
+ if let Some(page) = combined {
+ print_admin_response(output, &AdminResponse::Services(page))?;
+ }
+ Ok(())
+}
+
+async fn stream_connection_pages(
+ remote_addr: std::net::SocketAddr,
+ output: OutputFormat,
+ key_id: Option