diff --git a/Cargo.lock b/Cargo.lock index 04c5d0f..044b712 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,6 +39,7 @@ name = "andromeda-core" version = "0.1.0" dependencies = [ "chrono", + "ed25519-dalek", "serde", "serde_json", "thiserror", @@ -49,6 +50,7 @@ dependencies = [ name = "andromeda-hardware" version = "0.1.0" dependencies = [ + "andromeda-core", "chrono", "ed25519-dalek", "serde", @@ -95,10 +97,12 @@ dependencies = [ "serde", "serde_json", "tempfile", + "thiserror", "tokio", "tower", "tracing", "tracing-subscriber", + "uuid", ] [[package]] diff --git a/README.md b/README.md index f043f79..a58b7ff 100644 --- a/README.md +++ b/README.md @@ -354,11 +354,15 @@ RUST_LOG=info cargo run --locked --bin andromeda-taskd ``` ```bash -curl http://127.0.0.1:7777/healthz +curl -H "Authorization: Bearer $(cat .andromeda/taskd-token)" http://127.0.0.1:7777/healthz ``` -It listens on `127.0.0.1:7777` with state directory `.andromeda/state` by default. -**The API currently has no authentication of any kind; it must not be rebound off loopback.** +It listens on `127.0.0.1:7777` with state directory `.andromeda/state` by default, and +generates a `0600` API token at `.andromeda/taskd-token` on first start. +**Every request needs that token, `/healthz` included; there is no way to turn it off.** +The token only distinguishes "this host's service account or root" from other local users — +there is still no remote authentication and no user identity, so it must not be rebound off +loopback. --- @@ -503,12 +507,17 @@ Exit codes for `hardware check`: ## HTTP API reference -`andromeda-taskd` flags: `--listen` (`ANDROMEDA_LISTEN`, default `127.0.0.1:7777`) and -`--state-dir` (`ANDROMEDA_STATE_DIR`, default `.andromeda/state`). +`andromeda-taskd` flags: `--listen` (`ANDROMEDA_LISTEN`, default `127.0.0.1:7777`), +`--state-dir` (`ANDROMEDA_STATE_DIR`, default `.andromeda/state`), `--auth-token-file` +(`ANDROMEDA_AUTH_TOKEN_FILE`, default `.andromeda/taskd-token`), and `--capability-keyring` +(`ANDROMEDA_CAPABILITY_KEYRING`, unset by default). + +**Every path below, including `/healthz`, requires `Authorization: Bearer `** and +returns 401 `unauthorized` otherwise. See [Local authentication](#local-authentication). | Method | Path | Purpose | |---|---|---| -| `GET` | `/healthz` | Service status and API version | +| `GET` | `/healthz` | Service status, API version, and the running security posture: `authentication` (always `bearer_token`) and `capability_admission` (`unsigned_allowed` or `require_signed`) | | `POST` | `/v1/tasks` | Validate and create a task | | `GET` | `/v1/tasks` | List tasks as `{"tasks": [...], "warnings": [...]}`; a corrupt record file is skipped and reported in `warnings` instead of failing the whole listing | | `GET` | `/v1/tasks/{id}` | Read one task | @@ -522,12 +531,14 @@ Errors are uniformly `{"error": , "message": }`: | HTTP | `error` | Trigger | |---|---|---| | 400 | `bad_request` | Task id is not a valid UUID, etc. | +| 401 | `unauthorized` | Missing, malformed, or wrong `Authorization: Bearer` token | | 403 | `forbidden_host` | `Host` is not loopback | | 404 | `not_found` | No such task | | 409 | `already_exists` | Duplicate `task_id` | | 409 | `revision_conflict` | Stale `expected_revision` | | 422 | `external_confirmation_required` | `Ready -> Running` was attempted without confirmation while the plan contains L3 external side effects | | 422 | `missing_evidence` | `Verifying -> Succeeded` was attempted while some action lacks a recorded outcome, has an unsuccessful outcome, or has an outcome without evidence | +| 422 | `capability_not_admitted` | Under `require_signed`, a capability was unsigned, signed by an unknown key, malformed, or altered after issuance | | 422 | `invalid_task` | Plan validation failure, illegal state transition, or another policy-gated refusal (ungranted plan, policy-denied action) | | 500 | `internal_error` | Serialization or internal failure | @@ -536,6 +547,66 @@ Request bodies use `deny_unknown_fields`: a camelCase typo such as `expiresAt` i `tokio::task::spawn_blocking`, so blocking file locks and fsyncs never occupy async workers and `/healthz` stays responsive even while the store lock is contended. +### Local authentication + +Every request must carry `Authorization: Bearer `, `/healthz` included. A missing, +malformed, or wrong token gets 401 `unauthorized` with `WWW-Authenticate: Bearer` and no +explanation of which of the three it was. The comparison is constant-time. + +**The guarantee lives in the serve wiring, not in a config check.** `andromeda_taskd::app` +takes an `Authenticator` by value; that type has no `Default`, no public fields, and no variant +meaning "no authentication", and every constructor is fallible and rejects an empty or +under-32-character secret. An unauthenticated listener is therefore **not representable** — +there is no flag, environment variable, or unit directive that can produce one. Authentication +is the outermost layer, so an unauthenticated request is rejected before the Host check, before +body parsing, and before any store lock is taken. + +The token file (`--auth-token-file`) is generated on first start as 32 CSPRNG bytes in hex, +written atomically at mode `0600`, and reused across restarts. `taskd` refuses to start if the +containing directory grants anything to group or other. That protection model — directory +`0700`, file `0600` — is defined once in `crates/andromeda-taskd/src/auth.rs`, asserted at +startup, and a unit test asserts the shipped `andromeda-taskd.service` agrees with it. No file +in the repository hardcodes a uid or gid: the service identity is systemd's `DynamicUser`, and +systemd is the only creator of the directory. + +Operationally this narrows the caller set from "any local process or user" to "the service +account and root": + +```bash +curl -H "Authorization: Bearer $(sudo cat /run/andromeda-taskd/token)" \ + http://127.0.0.1:7777/healthz +``` + +> [!IMPORTANT] +> This does **not** defend against an attacker who already has root or the service account, and +> it is **not** remote authentication or user identity. The token is a single shared secret, so +> it cannot distinguish one caller from another and is not yet usable as a policy `subject`. + +### Capability admission (signatures) + +A `Capability` may carry a detached ed25519 signature from a trusted issuer. `/healthz` reports +which mode is in force: + +- `unsigned_allowed` (default, and the mode the shipped image runs in) — unsigned capabilities + are accepted. **Not a security boundary**: an authenticated caller still mints its own grants. +- `require_signed` — enabled by `--capability-keyring`, a JSON file of + `{"key_id": "<64 hex chars>"}`. Both the create and grant paths then reject any capability + that is unsigned, signed by an unknown key, malformed, or altered after issuance, with 422 + `capability_not_admitted`. An empty keyring fails startup rather than silently rejecting + everything while looking hardened. + +> [!IMPORTANT] +> Signatures do **not** by themselves close the self-issuance gap: whoever holds the private key +> is the issuer, and no component in this repository issues capabilities yet — which is why the +> shipped image deliberately configures no keyring. The gap closes when a trusted host component +> owns the key and the requesting process cannot reach it. See +> [threat model](docs/andromeda-threat-model.md) §4.2 and §6.2. + +The signature field is optional, so an unsigned capability serializes byte-identically to +earlier versions and existing task records keep parsing after an upgrade. Verification always +runs **after** the `MAX_TASK_CAPABILITIES` length bound, so an unbounded caller-supplied vector +can never force unbounded ed25519 work. + ### Host validation (DNS rebinding defense) `taskd` validates the `Host` header of every request (falling back to `:authority` under @@ -547,10 +618,10 @@ attacker's Host and is rejected. > [!CAUTION] > Host validation **only defends against browser-originated DNS rebinding; it is not > authentication**, and it does not protect a non-loopback binding. Any non-browser client can -> simply send `Host: localhost` and pass. If `ANDROMEDA_LISTEN` is changed to a non-loopback -> address, the API is exposed **without authentication** to that network. Separately, any local -> process or user can reach the API over loopback without authentication. -> **Do not bind `taskd` off loopback.** +> simply send `Host: localhost` and pass — it still needs the bearer token, but the two checks +> are independent and neither substitutes for the other. If `ANDROMEDA_LISTEN` is changed to a +> non-loopback address, the whole API sits on that network behind a single shared secret +> designed for same-host callers. **Do not bind `taskd` off loopback.** --- @@ -721,13 +792,21 @@ Detailed rules are in succeeded or was skipped and carries at least one piece of evidence; - task writes use atomic replacement, cross-process locking, and optimistic revision checks; - `taskd` refuses to bind to a non-loopback address at startup unless explicitly overridden; +- **every `taskd` request is authenticated**: an unauthenticated listener is not representable + in the type system, so no flag, environment variable, or unit directive can produce one; +- a capability signature, when a keyring is configured, is verified fail-closed — and always + after a length bound, so mandatory verification can never be handed an unbounded input; - hardware reports omit serial numbers and do not themselves grant a support tier. ### What does not hold yet -- `taskd` has **no authentication of any kind**: any local process reaching loopback can drive - the full API and mint its own capabilities. The `Host` header check defends browsers against - DNS rebinding only; +- **capabilities are still self-issued**: the signing and verification mechanism exists, but no + component issues capabilities, so the shipped image runs `unsigned_allowed` and an + authenticated caller still mints its own grants. Whoever holds the private key is the issuer; +- `taskd`'s local token is **not user identity**: it is a single shared secret that separates + the service account and root from other local users. There is no remote authentication, no + multi-tenancy, and the token cannot serve as a policy `subject`. The `Host` header check + defends browsers against DNS rebinding only and is not authentication; - isolation levels are **asserted by the caller, not attested** by an execution environment — the CLI's `--isolation` is a policy simulation, not a sandbox proof, and no sandbox exists; - the L3 confirmation is **caller-asserted, not broker-attested**: it proves a commit point was @@ -741,8 +820,8 @@ Detailed rules are in - the following are **not implemented**, and no integration may imply otherwise: model invocation and planner, bubblewrap/SELinux/microVM executor, credential broker, confirmation broker, external connector/MCP broker, signed policy bundles, independent verifier and - rollback/compensation executors, local caller authentication, user identity and remote - authentication, multi-tenancy, and the Task Center GUI. + rollback/compensation executors, **a trusted capability issuer** (verification exists; nothing + issues), user identity and remote authentication, multi-tenancy, and the Task Center GUI. The full trust-boundary analysis, including the known-unfixed attack surface, is in diff --git a/README.zh-CN.md b/README.zh-CN.md index b9ffa59..ec4c6e0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -340,11 +340,14 @@ RUST_LOG=info cargo run --locked --bin andromeda-taskd ``` ```bash -curl http://127.0.0.1:7777/healthz +curl -H "Authorization: Bearer $(cat .andromeda/taskd-token)" http://127.0.0.1:7777/healthz ``` -默认监听 `127.0.0.1:7777`,状态目录 `.andromeda/state`。 -**当前 API 没有任何认证,禁止改为非 loopback 监听。** +默认监听 `127.0.0.1:7777`,状态目录 `.andromeda/state`,首次启动会在 +`.andromeda/taskd-token` 生成权限为 `0600` 的 API 令牌。 +**每个请求都需要该令牌,`/healthz` 也不例外,且无法关闭。** +该令牌只把本机的服务账号与 root 同其他本地用户区分开——仍然没有远程认证与用户身份, +因此禁止改为非 loopback 监听。 --- @@ -478,11 +481,16 @@ stateDiagram-v2 ## HTTP API 参考 `andromeda-taskd` 参数:`--listen`(`ANDROMEDA_LISTEN`,默认 `127.0.0.1:7777`)、 -`--state-dir`(`ANDROMEDA_STATE_DIR`,默认 `.andromeda/state`)。 +`--state-dir`(`ANDROMEDA_STATE_DIR`,默认 `.andromeda/state`)、 +`--auth-token-file`(`ANDROMEDA_AUTH_TOKEN_FILE`,默认 `.andromeda/taskd-token`)、 +`--capability-keyring`(`ANDROMEDA_CAPABILITY_KEYRING`,默认不设置)。 + +**以下所有路径(含 `/healthz`)都必须携带 `Authorization: Bearer <令牌>`**,否则返回 +401 `unauthorized`。详见下方"本地鉴权(强制,不可关闭)"一节。 | 方法 | 路径 | 作用 | |---|---|---| -| `GET` | `/healthz` | 服务状态与 API 版本 | +| `GET` | `/healthz` | 服务状态、API 版本,以及当前安全姿态:`authentication`(恒为 `bearer_token`)与 `capability_admission`(`unsigned_allowed` / `require_signed`) | | `POST` | `/v1/tasks` | 校验并创建任务 | | `GET` | `/v1/tasks` | 列出任务,响应为 `{"tasks": [...], "warnings": [...]}`;损坏的记录文件被跳过并记入 `warnings`,不会让整个列表失败 | | `GET` | `/v1/tasks/{id}` | 读取任务 | @@ -496,12 +504,14 @@ stateDiagram-v2 | HTTP | `error` | 触发条件 | |---|---|---| | 400 | `bad_request` | task id 不是合法 UUID 等 | +| 401 | `unauthorized` | 缺少、格式错误或不匹配的 `Authorization: Bearer` 令牌 | | 403 | `forbidden_host` | `Host` 不是 loopback | | 404 | `not_found` | 任务不存在 | | 409 | `already_exists` | 重复的 `task_id` | | 409 | `revision_conflict` | `expected_revision` 过期 | | 422 | `external_confirmation_required` | 计划含 L3 外部副作用,而 `Ready → Running` 未携带确认 | | 422 | `missing_evidence` | `Verifying → Succeeded` 时仍有 action 缺少已记录 outcome、outcome 非成功、或 outcome 不含 evidence | +| 422 | `capability_not_admitted` | `require_signed` 模式下,capability 未签名、由未知密钥签发、格式错误或签名后被篡改 | | 422 | `invalid_task` | 计划校验失败、非法状态转换、或其余策略门控拒绝(计划未完全授权、action 被策略 Deny) | | 500 | `internal_error` | 序列化或内部故障 | @@ -509,6 +519,54 @@ stateDiagram-v2 而不是被静默丢弃。所有 `TaskService` 调用都在 `tokio::task::spawn_blocking` 中执行, 阻塞的文件锁与 fsync 不会占用 async worker,`/healthz` 在锁竞争时依旧可响应。 +### 本地鉴权(强制,不可关闭) + +每个请求都必须携带 `Authorization: Bearer <令牌>`,`/healthz` 也不例外。缺失、格式错误或 +不匹配一律 401 `unauthorized`,响应带 `WWW-Authenticate: Bearer`,且**不区分**是三者中的哪一种 +(区分只会帮助攻击者试探)。令牌比较是常数时间的。 + +**保证点在 serve 接线,而不是配置校验**:`andromeda_taskd::app` 按值接收 `Authenticator`; +该类型没有 `Default`、没有公开字段、没有任何表示"无鉴权"的变体,所有构造函数都可失败并拒绝 +空或短于 32 字符的秘密。因此**匿名监听在类型上不可表示**——不存在能产生它的命令行开关、 +环境变量或单元指令。鉴权是**最外层**中间件,未认证请求在 Host 校验、请求体解析和任何 +存储锁之前就被拒绝。 + +令牌文件(`--auth-token-file`)在首次启动时生成 32 字节 CSPRNG 随机值(十六进制), +以 `0600` 原子写入,重启复用。若其所在目录对 group/other 有任何权限,taskd 拒绝启动。 +该保护模型——目录 `0700`、文件 `0600`——**只在 `crates/andromeda-taskd/src/auth.rs` 定义一处**, +启动时断言,并由单元测试断言镜像内的 `andromeda-taskd.service` 与之一致。 +仓库中**没有任何文件写死 uid/gid**:服务身份就是 systemd 的 `DynamicUser`,目录只由 systemd 创建。 + +实际效果是把调用方从"本机任意进程/用户"收敛到"**服务账号与 root**": + +```bash +curl -H "Authorization: Bearer $(sudo cat /run/andromeda-taskd/token)" \ + http://127.0.0.1:7777/healthz +``` + +> [!IMPORTANT] +> 这**不**防御已取得 root 或服务账号的攻击者,也**不是**远程认证或用户身份。 +> 令牌是单一共享秘密,无法区分不同调用方,因此尚不能作为策略评估的 `subject`。 + +### capability 准入(签名) + +`Capability` 可携带受信签发方的 detached ed25519 签名。`/healthz` 报告当前模式: + +- `unsigned_allowed`(默认,也是镜像内的模式):接受未签名 capability。 + **这不是安全边界**——通过认证的调用方仍可自铸任意 capability。 +- `require_signed`:由 `--capability-keyring` 指定 JSON `{"key_id": "<64 位十六进制>"}` 启用。 + 此后创建与补授两条路径都会以 422 `capability_not_admitted` 拒绝未签名、未知密钥、 + 格式错误或签名后被篡改的 capability。空 keyring 直接启动失败,而不是伪装成已加固却拒绝一切。 + +> [!IMPORTANT] +> 签名本身**并不**关闭"能力自签发":持私钥者即签发方,而本仓库尚无任何组件签发 capability +> ——这正是镜像刻意不配置 keyring 的原因。该缺口要等到受信宿主组件持有密钥、 +> 且请求方够不到它时才闭合。见[威胁模型](docs/andromeda-threat-model.md) §4.2、§6.2。 + +签名字段是可选的,未签名 capability 的序列化结果与旧版本逐字节一致,升级后已持久化的记录 +照常解析。验签**永远**在 `MAX_TASK_CAPABILITIES` 长度上界之后执行,因此调用方无法用无界向量 +迫使无界的 ed25519 计算。 + ### Host 校验(DNS rebinding 防护) `taskd` 校验每个请求的 `Host`(HTTP/2 下回退到 `:authority`),只接受 `localhost` 与 @@ -517,9 +575,9 @@ stateDiagram-v2 > [!CAUTION] > Host 校验**只防御浏览器发起的 DNS rebinding,不是鉴权**,也不能保护非 loopback 绑定。 -> 任何非浏览器客户端都可以自带 `Host: localhost` 通过校验。若把 `ANDROMEDA_LISTEN` 改为 -> 非 loopback 地址,API 会向该网络**无鉴权暴露**。此外,本地任意进程/用户经 loopback -> 亦可无鉴权访问。**禁止把 `taskd` 绑定到 loopback 之外。** +> 任何非浏览器客户端都可以自带 `Host: localhost` 通过校验——但仍需持有本地令牌;两层检查 +> 相互独立,谁也不能替代谁。若把 `ANDROMEDA_LISTEN` 改为非 loopback 地址,整个 API 就会 +> 以一个为同机调用设计的共享秘密暴露在该网络上。**禁止把 `taskd` 绑定到 loopback 之外。** --- @@ -672,12 +730,19 @@ HCM 是一份声明 selector、requirements、kernel channel、artifacts 与 evi 状态为成功或跳过,且**至少携带一条 evidence**; - 任务写入使用原子替换、跨进程锁和乐观 revision 校验; - `taskd` 启动时拒绝绑定到非回环地址,除非显式 opt-out; +- **`taskd` 的每个请求都经过鉴权**:匿名监听在类型系统中不可表示,因此没有任何开关、 + 环境变量或单元指令能产生它; +- 配置了 keyring 时,capability 签名以 fail-closed 方式校验,且**永远**在长度上界之后执行, + 强制验签因此不可能拿到无界输入; - 硬件报告不含序列号,且其本身不授予支持等级。 ### 当前尚不成立的部分 -- `taskd` **没有任何鉴权**:本地任意进程经 loopback 即可驱动全部 API 并自签发 capability。 - `Host` 头校验只防御浏览器 DNS rebinding; +- **capability 仍是自签发的**:签名与验签机制已存在,但没有任何组件签发 capability, + 因此镜像运行在 `unsigned_allowed`,通过认证的调用方依旧可以自铸授权。持私钥者即签发方; +- `taskd` 的本地令牌**不是用户身份**:它是单一共享秘密,只把服务账号与 root 同其他本地用户 + 区分开。没有远程认证、没有多租户,该令牌也不能充当策略 `subject`。 + `Host` 头校验只防御浏览器 DNS rebinding,不是鉴权; - 隔离等级由**调用方自报,而非执行环境证明**——CLI 的 `--isolation` 只是策略模拟, 不是沙箱证明,且当前不存在任何沙箱; - L3 确认是**调用方自报,而非 broker 证明**:它证明"确认这一步发生过并被归属", @@ -690,8 +755,8 @@ HCM 是一份声明 selector、requirements、kernel channel、artifacts 与 evi - 以下均**未实现**,任何集成都不得暗示其存在:模型调用与 planner、 bubblewrap/SELinux/microVM executor、credential broker、确认代理、 外部 connector/MCP broker、签名 policy bundle、独立 verifier 与 - rollback/compensation executor、本地调用方认证、用户身份与远程认证、 - 多租户、Task Center 图形界面。 + rollback/compensation executor、**受信 capability 签发方**(验签已实现,无人签发)、 + 用户身份与远程认证、多租户、Task Center 图形界面。 完整的信任边界分析与已知未修攻击面见 diff --git a/SECURITY.md b/SECURITY.md index 068e260..ddbd487 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Project maturity -Andromeda is a v0 engineering prototype and is not a production operating system. The current task service is non-privileged, has no authentication, and binds to loopback by default. Do not expose it to untrusted networks or use it to protect production secrets. +Andromeda is a v0 engineering prototype and is not a production operating system. The current task service is non-privileged, binds to loopback by default, and requires a local bearer token on every request — a token that separates this host's service account and root from other local users, and is not remote authentication or user identity. Do not expose it to untrusted networks or use it to protect production secrets. ## Reporting a vulnerability @@ -37,4 +37,6 @@ Two boundaries are called out here because integrations get them wrong: - `taskd` refuses to bind to a non-loopback address at startup unless explicitly overridden. The `Host` header check defends browsers against DNS rebinding only and is not authentication. - Hardware reports omit serial numbers and do not themselves grant a support tier. -The current runtime evaluates policies but does not execute tools. Executor, sandbox/microVM attestation, credential broker, independent verifier, confirmation broker, signed policy bundles, append-only audit, local caller authentication, and remote authentication are not implemented yet and must not be implied by integrations. Signed-manifest (HCM) verification is implemented in the hardware library but is not wired to any CLI entry point, so it provides no protection in practice yet. In particular, `taskd` has **no authentication of any kind**: any local process reaching loopback can drive the full API and mint its own capabilities. +The current runtime evaluates policies but does not execute tools. Executor, sandbox/microVM attestation, credential broker, independent verifier, confirmation broker, signed policy bundles, append-only audit, a trusted capability issuer, user identity, and remote authentication are not implemented yet and must not be implied by integrations. Signed-manifest (HCM) verification is implemented in the hardware library but is not wired to any CLI entry point, so it provides no protection in practice yet. + +`taskd` now authenticates every request against a local bearer token, and an unauthenticated listener is not representable in the type system, so no configuration can turn it off. Two limits matter and must not be overstated: the token is a single shared secret protected by file permissions, so it separates the service account and root from other local users but is neither user identity nor remote authentication; and **capabilities are still self-issued** — verification, keyrings, and fail-closed refusal all exist, but no component issues capabilities, so the shipped image accepts unsigned grants and an authenticated caller can still mint its own. Whoever holds the signing key is the issuer. diff --git a/crates/andromeda-cli/src/main.rs b/crates/andromeda-cli/src/main.rs index 81aafd9..c7f494b 100644 --- a/crates/andromeda-cli/src/main.rs +++ b/crates/andromeda-cli/src/main.rs @@ -14,7 +14,7 @@ use andromeda_hardware::{ }; use andromeda_policy::PolicyEngine; use andromeda_runtime::{ - CreateTaskRequest, EvaluationRequest, FileTaskStore, RecordOutcomeRequest, + CapabilityAdmission, CreateTaskRequest, EvaluationRequest, FileTaskStore, RecordOutcomeRequest, StateTransitionRequest, TaskService, }; use chrono::Utc; @@ -386,8 +386,17 @@ fn main() -> Result<(), Box> { let cli = Cli::parse(); match cli.command { Command::Task { command } => { - let service = - TaskService::new(FileTaskStore::open(cli.state_dir)?, PolicyEngine::default()); + // The developer CLI drives a local store directly, with no daemon, + // no network, and no caller to authenticate: whoever runs it can + // already write the state directory. Requiring issuer signatures + // here would protect nothing the filesystem does not already + // decide, so it names the permissive posture explicitly rather + // than pretending to a guarantee it cannot make. + let service = TaskService::new( + FileTaskStore::open(cli.state_dir)?, + PolicyEngine::default(), + CapabilityAdmission::unsigned_for_development(), + ); handle_task(&service, command)?; } Command::Hardware { command } => handle_hardware(command)?, @@ -623,6 +632,7 @@ fn create_inspection_request( issued_at: Utc::now(), expires_at: None, single_use: false, + signature: None, }; let plan = ActionPlan { schema_version: ActionPlan::CURRENT_SCHEMA_VERSION, diff --git a/crates/andromeda-core/Cargo.toml b/crates/andromeda-core/Cargo.toml index f84fee2..a99d99c 100644 --- a/crates/andromeda-core/Cargo.toml +++ b/crates/andromeda-core/Cargo.toml @@ -9,12 +9,24 @@ rust-version.workspace = true [dependencies] chrono.workspace = true +# Declared here (not in `[workspace.dependencies]`) because only this crate and +# `andromeda-hardware` need ed25519, for detached capability and HCM manifest +# signature verification. Same version and same feature set as the hardware +# crate's declaration, so the two resolve to one `Cargo.lock` entry. No default +# `rand_core`/key-generation feature: keys are built from a caller-supplied +# seed, so no runtime RNG is ever invoked. `zeroize` keeps signing-key material +# from lingering in memory. +ed25519-dalek = { version = "2.2.0", default-features = false, features = [ + "std", + "zeroize", +] } serde.workspace = true +# Promoted from a dev-dependency: canonical JSON (`encoding::canonical_json`) is +# how a signed capability becomes a message, so it is part of the shipped code +# path now, not only of tests. Already in `Cargo.lock` either way. +serde_json.workspace = true thiserror.workspace = true uuid.workspace = true -[dev-dependencies] -serde_json.workspace = true - [lints] workspace = true diff --git a/crates/andromeda-core/src/capability.rs b/crates/andromeda-core/src/capability.rs index f33362b..a44fbd4 100644 --- a/crates/andromeda-core/src/capability.rs +++ b/crates/andromeda-core/src/capability.rs @@ -80,6 +80,21 @@ pub enum CapabilityResource { ExternalService { service: String, operation: String }, } +/// A detached ed25519 signature by the issuer that vouched for a capability. +/// +/// `key_id` selects the verifying key from a +/// [`CapabilityKeyring`](crate::capability_signing::CapabilityKeyring); `sig` +/// is the 64-byte ed25519 signature as 128 lowercase hex characters, over the +/// bytes produced by +/// [`canonical_signing_bytes`](crate::capability_signing::canonical_signing_bytes). +/// Hex, not base64, matching every other opaque byte string in this workspace. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CapabilitySignature { + pub key_id: String, + pub sig: String, +} + /// A scoped permission proposed by a plan and granted by host policy or a user. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -89,6 +104,27 @@ pub struct Capability { pub issued_to: String, pub issued_at: DateTime, pub expires_at: Option>, + /// Detached signature by a trusted issuer, when one exists. + /// + /// **`Option`, deliberately.** No capability *issuer* exists in this + /// repository yet, so making the field mandatory would reject every + /// capability that can be produced today — including every record already + /// persisted by a running `taskd`. Instead the requirement lives in + /// deployment configuration: `andromeda-runtime`'s `CapabilityAdmission` + /// refuses unsigned capabilities whenever a keyring is configured, and the + /// type keeps parsing old, unsigned records so an upgrade does not orphan + /// them. + /// + /// Omitted from serialized output when absent, so an unsigned capability's + /// JSON is byte-identical to what earlier versions wrote. + /// + /// A signature says "a holder of this key vouched for this grant". It does + /// **not** say the grant is safe to execute, and on its own it does not + /// close the self-issuance gap: whoever holds the key is the issuer, and + /// today that is whoever runs the signing helper. The gap closes when a + /// trusted host component owns the key and the caller cannot reach it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signature: Option, /// Reserved; not yet enforced (no executor exists). Single-use /// consumption — invalidating the capability after its first successful /// execution — will belong to the runtime execution layer once it exists; @@ -212,6 +248,7 @@ mod tests { issued_at: Utc::now(), expires_at: None, single_use: false, + signature: None, } } @@ -317,12 +354,39 @@ mod tests { issued_at: Utc::now(), expires_at: Some(Utc::now() + chrono::Duration::minutes(5)), single_use: true, + signature: None, }; let encoded = serde_json::to_string(&capability).expect("serialize capability"); let decoded: Capability = serde_json::from_str(&encoded).expect("deserialize capability"); assert_eq!(decoded, capability); } + /// Adding `signature` must not break records written before it existed, + /// and must not change what an unsigned capability serializes to — a store + /// full of unsigned records has to survive the upgrade untouched. + #[test] + fn unsigned_capabilities_are_wire_compatible_in_both_directions() { + let capability = file_capability("/work/project", FileAccess::Read); + let encoded = serde_json::to_value(&capability).expect("serialize"); + assert!( + encoded.get("signature").is_none(), + "an unsigned capability must not emit a signature field: {encoded}" + ); + + // A record written by an older build (no `signature` key at all) still + // parses, and parses as unsigned rather than as anything else. + let legacy = serde_json::json!({ + "id": capability.id, + "resource": { "type": "files", "root": "/work/project", "access": "read" }, + "issued_to": "task", + "issued_at": capability.issued_at, + "expires_at": null, + "single_use": false, + }); + let decoded: Capability = serde_json::from_value(legacy).expect("legacy record parses"); + assert_eq!(decoded.signature, None); + } + #[test] fn capability_rejects_unknown_fields() { // A camelCase typo of `expires_at` must now be rejected outright diff --git a/crates/andromeda-core/src/capability_signing.rs b/crates/andromeda-core/src/capability_signing.rs new file mode 100644 index 0000000..6283ee8 --- /dev/null +++ b/crates/andromeda-core/src/capability_signing.rs @@ -0,0 +1,625 @@ +//! Detached ed25519 authenticity for [`Capability`] grants. +//! +//! Security review finding #3 records that a capability is *self-asserted*: the +//! caller hands `taskd` a plan and the capabilities for it in one request, and +//! the only subject binding — `issued_to == plan.task_id` — is satisfied by a +//! `task_id` the same caller chose. This module supplies the missing piece: a +//! way for an issuer that is **not** the caller to vouch for a grant, and a way +//! for `taskd` to check that claim without holding any private key. +//! +//! ## What this achieves today, and what it does not +//! +//! There is no capability issuer, no executor, and no host broker in this +//! repository. A signature therefore proves exactly one thing right now: *the +//! holder of key `key_id` produced these grant bytes, and nobody has altered +//! them since*. It does **not** by itself close finding #3, because whoever can +//! run the signing helper is the issuer. The gap closes only when a trusted +//! host component owns the key material and the requesting process cannot reach +//! it — see `docs/andromeda-threat-model.md` §4.2. +//! +//! What it does buy today is real: with a keyring configured, `taskd` will +//! refuse a capability that no configured key vouched for, and any tampering +//! with an issued grant (widening a file root, deleting an expiry) invalidates +//! it. That turns "capabilities are unforgeable" from a documentation claim +//! into a checkable one, and it is the mechanism an issuer will plug into. +//! +//! ## Shape +//! +//! Mirrors `andromeda_hardware::signing`, deliberately: one canonicalization +//! function that both signing and verification go through, a keyring keyed by +//! `key_id`, a status enum whose only accepting variant is `Verified`, hex +//! encoding, `verify_strict`, and seed-derived keys so tests never touch an +//! RNG. The two schemes share [`crate::encoding`] so their canonical JSON and +//! hex can never drift apart. + +use std::collections::BTreeMap; + +use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; + +use crate::capability::{Capability, CapabilitySignature}; +use crate::encoding::{canonical_json, hex}; + +/// Domain separator prefixed to every capability message. +/// +/// A signature over a bare JSON object is a signature over *any* context that +/// object could be lifted into. Binding the message to this scheme means a +/// capability signature can never be replayed as a signature over some other +/// Andromeda structure, even if a future type serializes to the same JSON. +/// (HCM manifest signatures predate this module and are not prefixed; the two +/// message shapes are disjoint, and changing manifest bytes would invalidate +/// signatures already issued against them.) +const DOMAIN: &[u8] = b"andromeda-capability-v1\n"; + +/// Errors from loading key material or canonicalizing a capability. +/// +/// Distinct from a verification *verdict* (see [`CapabilitySignatureStatus`]): +/// these are malformed inputs a caller can fix, not "this grant is untrusted". +#[derive(Debug, thiserror::Error)] +pub enum SignatureError { + #[error("invalid hex encoding: {0}")] + Hex(String), + #[error("ed25519 verifying key must be 32 bytes, got {0}")] + KeyLength(usize), + #[error("invalid ed25519 verifying key: {0}")] + VerifyingKey(String), + #[error("could not canonicalize capability: {0}")] + Canonicalize(String), + #[error("a keyring that trusts no keys cannot authenticate anything")] + EmptyKeyring, +} + +/// The verdict of checking a capability's signature against a keyring. +/// +/// Only [`Verified`](CapabilitySignatureStatus::Verified) is an accept; every +/// other variant is a reason the capability fails closed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CapabilitySignatureStatus { + /// The capability carried a signature by `key_id`, that key is in the + /// keyring, and the ed25519 signature verified over the canonical bytes. + Verified { key_id: String }, + /// The capability carried no `signature` field. + Unsigned, + /// The `signature.key_id` is not present in the keyring. + UnknownKey { key_id: String }, + /// The signature bytes were not decodable / not 64 bytes, or the capability + /// could not be canonicalized. + Malformed { reason: String }, + /// The key resolved but the ed25519 signature did not verify — the grant + /// was altered after issuance, or signed by a different key. + Invalid { key_id: String, reason: String }, +} + +impl CapabilitySignatureStatus { + /// Whether this verdict admits the capability. Exactly one variant does. + #[must_use] + pub const fn is_verified(&self) -> bool { + matches!(self, Self::Verified { .. }) + } + + /// A short reason suitable for an error message or audit event. Returns + /// `None` for [`Verified`](CapabilitySignatureStatus::Verified). + #[must_use] + pub fn rejection_reason(&self) -> Option { + match self { + Self::Verified { .. } => None, + Self::Unsigned => Some("capability carries no issuer signature".to_owned()), + Self::UnknownKey { key_id } => { + Some(format!("signing key '{key_id}' is not in the keyring")) + } + Self::Malformed { reason } => Some(format!("malformed signature: {reason}")), + Self::Invalid { key_id, reason } => { + Some(format!("signature by '{key_id}' did not verify: {reason}")) + } + } + } +} + +/// A set of trusted ed25519 verifying keys, indexed by `key_id`. +/// +/// This is the trust anchor for capability issuance: a grant is authentic only +/// if it resolves to a key in here. An empty keyring trusts nothing, which is +/// why [`CapabilityKeyring::require_non_empty`] exists — a configuration path +/// that accidentally produced an empty keyring would otherwise reject every +/// request while looking like it had enabled a security feature. +#[derive(Debug, Clone, Default)] +pub struct CapabilityKeyring { + keys: BTreeMap, +} + +impl CapabilityKeyring { + /// An empty keyring that trusts no keys. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Adds a verifying key given as 64 hex characters (32 raw bytes). A later + /// insert with the same `key_id` replaces the earlier key. + /// + /// # Errors + /// Returns [`SignatureError`] if the hex is malformed, is not 32 bytes, or + /// is not a valid ed25519 point. + pub fn insert_hex( + &mut self, + key_id: impl Into, + verifying_key_hex: &str, + ) -> Result<(), SignatureError> { + let bytes = hex::decode(verifying_key_hex) + .map_err(|error| SignatureError::Hex(error.to_string()))?; + let array: [u8; 32] = bytes + .as_slice() + .try_into() + .map_err(|_| SignatureError::KeyLength(bytes.len()))?; + let key = VerifyingKey::from_bytes(&array) + .map_err(|error| SignatureError::VerifyingKey(error.to_string()))?; + self.keys.insert(key_id.into(), key); + Ok(()) + } + + /// Builds a keyring from `(key_id, verifying_key_hex)` pairs — for example + /// a parsed `{ "key-id": "" }` trusted-keys file. + /// + /// # Errors + /// Returns the first [`SignatureError`] encountered while decoding a key. + pub fn from_hex_entries(entries: I) -> Result + where + I: IntoIterator, + { + let mut keyring = Self::new(); + for (key_id, key_hex) in entries { + keyring.insert_hex(key_id, &key_hex)?; + } + Ok(keyring) + } + + /// Returns the keyring only if it holds at least one key. + /// + /// # Errors + /// Returns [`SignatureError::EmptyKeyring`] when the keyring is empty. + pub fn require_non_empty(self) -> Result { + if self.is_empty() { + Err(SignatureError::EmptyKeyring) + } else { + Ok(self) + } + } + + /// Whether the keyring holds no keys. + #[must_use] + pub fn is_empty(&self) -> bool { + self.keys.is_empty() + } + + /// The number of trusted keys. + #[must_use] + pub fn len(&self) -> usize { + self.keys.len() + } + + /// Whether `key_id` names a trusted key. + #[must_use] + pub fn contains(&self, key_id: &str) -> bool { + self.keys.contains_key(key_id) + } + + /// The trusted `key_id`s, sorted. + pub fn key_ids(&self) -> impl Iterator { + self.keys.keys().map(String::as_str) + } + + fn get(&self, key_id: &str) -> Option<&VerifyingKey> { + self.keys.get(key_id) + } +} + +/// A deterministic ed25519 signing key for issuing capability signatures. +/// +/// Built from a 32-byte seed, never from an RNG, so signing is reproducible in +/// tests and in an offline issuing tool. How a production seed is generated, +/// stored, and rotated is a deployment concern this type does not dictate — +/// and, importantly, `taskd` never constructs one: it holds verifying keys +/// only, so compromising the daemon does not yield the power to issue grants. +pub struct CapabilitySigningKey { + inner: SigningKey, +} + +impl CapabilitySigningKey { + /// Builds a signing key from a fixed 32-byte seed. + #[must_use] + pub fn from_seed(seed: &[u8; 32]) -> Self { + Self { + inner: SigningKey::from_bytes(seed), + } + } + + /// The matching verifying key as 64 lowercase hex characters — the value to + /// publish and load into a [`CapabilityKeyring`]. + #[must_use] + pub fn verifying_key_hex(&self) -> String { + hex::encode(self.inner.verifying_key().as_bytes()) + } + + /// Signs `capability`'s canonical bytes and returns a + /// [`CapabilitySignature`] tagged with `key_id`. Any signature already on + /// the capability is ignored (canonicalization strips it), so this is safe + /// to call on an already-signed grant. + /// + /// # Errors + /// Returns [`SignatureError::Canonicalize`] if the capability cannot be + /// serialized to canonical bytes. + pub fn sign( + &self, + capability: &Capability, + key_id: impl Into, + ) -> Result { + let message = canonical_signing_bytes(capability)?; + let signature = self.inner.sign(&message); + Ok(CapabilitySignature { + key_id: key_id.into(), + sig: hex::encode(&signature.to_bytes()), + }) + } + + /// Convenience for tests and issuing tools: signs `capability` and stores + /// the resulting signature on it. + /// + /// # Errors + /// Returns [`SignatureError::Canonicalize`] if the capability cannot be + /// serialized to canonical bytes. + pub fn sign_in_place( + &self, + capability: &mut Capability, + key_id: impl Into, + ) -> Result<(), SignatureError> { + capability.signature = Some(self.sign(capability, key_id)?); + Ok(()) + } +} + +/// Verifies a capability's detached signature against `keyring`. +/// +/// Pure and side-effect free. Callers turn any non-verified status into a +/// refusal; see `andromeda_runtime::CapabilityAdmission`. +#[must_use] +pub fn verify_capability_signature( + capability: &Capability, + keyring: &CapabilityKeyring, +) -> CapabilitySignatureStatus { + let Some(signature) = capability.signature.as_ref() else { + return CapabilitySignatureStatus::Unsigned; + }; + let Some(verifying_key) = keyring.get(&signature.key_id) else { + return CapabilitySignatureStatus::UnknownKey { + key_id: signature.key_id.clone(), + }; + }; + let signature_bytes = match hex::decode(&signature.sig) { + Ok(bytes) => bytes, + Err(error) => { + return CapabilitySignatureStatus::Malformed { + reason: format!("signature hex: {error}"), + }; + } + }; + let signature_array: [u8; 64] = match signature_bytes.as_slice().try_into() { + Ok(array) => array, + Err(_) => { + return CapabilitySignatureStatus::Malformed { + reason: format!( + "ed25519 signature must be 64 bytes, got {}", + signature_bytes.len() + ), + }; + } + }; + let message = match canonical_signing_bytes(capability) { + Ok(message) => message, + Err(error) => { + return CapabilitySignatureStatus::Malformed { + reason: error.to_string(), + }; + } + }; + // `verify_strict` rejects non-canonical signatures and small-order keys, + // closing signature-malleability gaps that plain `verify` would accept. + match verifying_key.verify_strict(&message, &Signature::from_bytes(&signature_array)) { + Ok(()) => CapabilitySignatureStatus::Verified { + key_id: signature.key_id.clone(), + }, + Err(error) => CapabilitySignatureStatus::Invalid { + key_id: signature.key_id.clone(), + reason: error.to_string(), + }, + } +} + +/// Serializes `capability` to the canonical byte string that is signed and +/// verified: [`DOMAIN`] followed by canonical JSON of the typed capability with +/// the `signature` field removed (a signature cannot cover itself). +/// +/// Serializing the *typed* model rather than any bytes the caller supplied is +/// what makes the encoding stable: whether an optional field arrived omitted or +/// as an explicit `null`, and in which order the fields were written, cannot +/// change the message. See [`crate::encoding::canonical_json`] for the rules. +/// +/// # Errors +/// Returns [`SignatureError::Canonicalize`] if the capability cannot be turned +/// into a JSON value. +pub fn canonical_signing_bytes(capability: &Capability) -> Result, SignatureError> { + let mut value = serde_json::to_value(capability) + .map_err(|error| SignatureError::Canonicalize(error.to_string()))?; + if let Some(object) = value.as_object_mut() { + object.remove("signature"); + } + let mut message = DOMAIN.to_vec(); + message.extend_from_slice(canonical_json::to_string(&value).as_bytes()); + Ok(message) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use chrono::{TimeZone as _, Utc}; + + use super::*; + use crate::capability::{CapabilityResource, FileAccess}; + + /// A fixed, non-random seed: signing must be reproducible, and no test may + /// depend on an RNG. Mirrors `andromeda-hardware`'s approach. Any 32 bytes + /// work. + const SEED: [u8; 32] = [ + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, + 0x00, 0x0f, 0x1e, 0x2d, 0x3c, 0x4b, 0x5a, 0x69, 0x78, 0x87, 0x96, 0xa5, 0xb4, 0xc3, 0xd2, + 0xe1, 0xf0, + ]; + + fn signing_key() -> CapabilitySigningKey { + CapabilitySigningKey::from_seed(&SEED) + } + + fn keyring_with(key_id: &str, key: &CapabilitySigningKey) -> CapabilityKeyring { + let mut keyring = CapabilityKeyring::new(); + keyring + .insert_hex(key_id.to_owned(), &key.verifying_key_hex()) + .expect("valid verifying key hex"); + keyring + } + + /// A fixed capability: no `Utc::now()`, so canonical bytes are stable. + fn capability() -> Capability { + Capability { + id: "6d1f0a4e-7c3b-4b0e-9f2a-1c5d8e3b7a90" + .parse() + .expect("fixed capability id"), + resource: CapabilityResource::Files { + root: PathBuf::from("/work/project"), + access: FileAccess::Read, + }, + issued_to: "task-1".to_owned(), + issued_at: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(), + expires_at: Some(Utc.with_ymd_and_hms(2026, 1, 2, 0, 0, 0).unwrap()), + single_use: false, + signature: None, + } + } + + #[test] + fn seed_is_deterministic() { + assert_eq!( + signing_key().verifying_key_hex(), + signing_key().verifying_key_hex() + ); + assert_eq!(signing_key().verifying_key_hex().len(), 64); + } + + #[test] + fn signed_capability_verifies_against_its_key() { + let key = signing_key(); + let mut capability = capability(); + key.sign_in_place(&mut capability, "issuer-2026").unwrap(); + let signature = capability.signature.clone().expect("signature"); + assert_eq!(signature.sig.len(), 128); + assert!(signature.sig.bytes().all(|b| b.is_ascii_hexdigit())); + assert_eq!( + verify_capability_signature(&capability, &keyring_with("issuer-2026", &key)), + CapabilitySignatureStatus::Verified { + key_id: "issuer-2026".to_owned() + } + ); + } + + #[test] + fn canonicalization_survives_a_json_round_trip() { + // A grant signed as a model must still verify after it has been + // persisted and re-parsed, or every restart would invalidate the store. + let key = signing_key(); + let mut capability = capability(); + key.sign_in_place(&mut capability, "issuer-2026").unwrap(); + let json = serde_json::to_string_pretty(&capability).unwrap(); + let reparsed: Capability = serde_json::from_str(&json).unwrap(); + assert!( + verify_capability_signature(&reparsed, &keyring_with("issuer-2026", &key)) + .is_verified() + ); + } + + /// The adversarial case this whole module exists for: an attacker takes a + /// legitimately issued narrow grant and widens it. + #[test] + fn widening_a_grant_after_signing_is_detected() { + let key = signing_key(); + let mut capability = capability(); + key.sign_in_place(&mut capability, "issuer-2026").unwrap(); + capability.resource = CapabilityResource::Files { + root: PathBuf::from("/"), + access: FileAccess::ReadWrite, + }; + assert!(matches!( + verify_capability_signature(&capability, &keyring_with("issuer-2026", &key)), + CapabilitySignatureStatus::Invalid { .. } + )); + } + + /// Dropping the expiry turns a scoped grant into a permanent one; the + /// threat model calls this out specifically (§4.2). + #[test] + fn removing_the_expiry_after_signing_is_detected() { + let key = signing_key(); + let mut capability = capability(); + key.sign_in_place(&mut capability, "issuer-2026").unwrap(); + capability.expires_at = None; + assert!(matches!( + verify_capability_signature(&capability, &keyring_with("issuer-2026", &key)), + CapabilitySignatureStatus::Invalid { .. } + )); + } + + /// Re-pointing a grant at another task must not survive either. + #[test] + fn changing_the_subject_after_signing_is_detected() { + let key = signing_key(); + let mut capability = capability(); + key.sign_in_place(&mut capability, "issuer-2026").unwrap(); + capability.issued_to = "task-2".to_owned(); + assert!(matches!( + verify_capability_signature(&capability, &keyring_with("issuer-2026", &key)), + CapabilitySignatureStatus::Invalid { .. } + )); + } + + #[test] + fn unsigned_capability_reports_unsigned() { + assert_eq!( + verify_capability_signature( + &capability(), + &keyring_with("issuer-2026", &signing_key()) + ), + CapabilitySignatureStatus::Unsigned + ); + } + + #[test] + fn unknown_key_id_is_reported() { + let key = signing_key(); + let mut capability = capability(); + key.sign_in_place(&mut capability, "rogue-issuer").unwrap(); + assert_eq!( + verify_capability_signature(&capability, &keyring_with("issuer-2026", &key)), + CapabilitySignatureStatus::UnknownKey { + key_id: "rogue-issuer".to_owned() + } + ); + } + + #[test] + fn a_different_key_under_a_trusted_id_fails_verification() { + let signer = CapabilitySigningKey::from_seed(&[9u8; 32]); + let trusted = signing_key(); + let mut capability = capability(); + signer + .sign_in_place(&mut capability, "issuer-2026") + .unwrap(); + assert!(matches!( + verify_capability_signature(&capability, &keyring_with("issuer-2026", &trusted)), + CapabilitySignatureStatus::Invalid { .. } + )); + } + + #[test] + fn malformed_signature_hex_is_reported() { + let mut capability = capability(); + capability.signature = Some(CapabilitySignature { + key_id: "issuer-2026".to_owned(), + sig: "not-hex".to_owned(), + }); + assert!(matches!( + verify_capability_signature(&capability, &keyring_with("issuer-2026", &signing_key())), + CapabilitySignatureStatus::Malformed { .. } + )); + } + + #[test] + fn short_signature_is_malformed_not_invalid() { + let mut capability = capability(); + capability.signature = Some(CapabilitySignature { + key_id: "issuer-2026".to_owned(), + sig: hex::encode(&[0u8; 32]), + }); + assert!(matches!( + verify_capability_signature(&capability, &keyring_with("issuer-2026", &signing_key())), + CapabilitySignatureStatus::Malformed { .. } + )); + } + + #[test] + fn empty_keyring_trusts_nothing() { + let key = signing_key(); + let mut capability = capability(); + key.sign_in_place(&mut capability, "issuer-2026").unwrap(); + assert!(matches!( + verify_capability_signature(&capability, &CapabilityKeyring::new()), + CapabilitySignatureStatus::UnknownKey { .. } + )); + assert!(CapabilityKeyring::new().require_non_empty().is_err()); + } + + #[test] + fn canonical_bytes_ignore_the_signature_field() { + let key = signing_key(); + let unsigned = capability(); + let mut signed = capability(); + key.sign_in_place(&mut signed, "issuer-2026").unwrap(); + assert_eq!( + canonical_signing_bytes(&unsigned).unwrap(), + canonical_signing_bytes(&signed).unwrap() + ); + } + + /// Domain separation is part of the message, not decoration: the bytes must + /// actually start with the tag, so a signature over a bare capability JSON + /// produced elsewhere cannot be replayed here. + #[test] + fn canonical_bytes_are_domain_separated() { + let bytes = canonical_signing_bytes(&capability()).unwrap(); + assert!(bytes.starts_with(DOMAIN)); + assert_eq!(bytes[DOMAIN.len()], b'{'); + } + + #[test] + fn signing_is_reproducible_from_the_seed() { + // Two independently constructed keys from the same seed must produce + // identical signature bytes; this is what makes fixed-seed tests + // meaningful rather than merely non-random. + let first = signing_key().sign(&capability(), "issuer-2026").unwrap(); + let second = signing_key().sign(&capability(), "issuer-2026").unwrap(); + assert_eq!(first, second); + } + + #[test] + fn rejection_reasons_are_reported_for_every_failure() { + assert!( + CapabilitySignatureStatus::Verified { + key_id: "k".to_owned() + } + .rejection_reason() + .is_none() + ); + for status in [ + CapabilitySignatureStatus::Unsigned, + CapabilitySignatureStatus::UnknownKey { + key_id: "k".to_owned(), + }, + CapabilitySignatureStatus::Malformed { + reason: "r".to_owned(), + }, + CapabilitySignatureStatus::Invalid { + key_id: "k".to_owned(), + reason: "r".to_owned(), + }, + ] { + assert!(!status.is_verified()); + assert!(status.rejection_reason().is_some(), "{status:?}"); + } + } +} diff --git a/crates/andromeda-core/src/encoding.rs b/crates/andromeda-core/src/encoding.rs new file mode 100644 index 0000000..b35c822 --- /dev/null +++ b/crates/andromeda-core/src/encoding.rs @@ -0,0 +1,206 @@ +//! Deterministic encodings shared by every Andromeda signature scheme. +//! +//! Two signature schemes now exist in this workspace — detached HCM manifest +//! signatures (`andromeda-hardware`) and detached capability signatures +//! ([`crate::capability_signing`]) — and both must agree, byte for byte, on +//! how a typed value becomes a message and how raw bytes become text. A second +//! copy of either encoder is a place where the two can silently drift, so both +//! live here and both schemes call these functions rather than their own. +//! +//! Neither encoder is cryptographic on its own; they only remove ambiguity. + +/// Lowercase hex, the workspace's single encoding for opaque byte strings. +/// +/// Keys, signatures, and digests are hex everywhere in this repository +/// (`ArtifactPin.sha256`, `ManifestSignature.sig`, verifying keys, the `taskd` +/// API token). Deliberately not base64: one encoding for one job means a +/// reader never has to ask which is in play, and it keeps a base64 crate out +/// of the dependency graph. +pub mod hex { + use std::fmt::Write as _; + + /// A hex string that could not be decoded. + /// + /// The message is the bare reason ("odd number of hex digits"), with no + /// "invalid hex" prefix: callers wrap this in their own error, and a prefix + /// here would show up doubled in the final message. + #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] + #[error("{reason}")] + pub struct HexError { + reason: String, + } + + impl HexError { + fn new(reason: impl Into) -> Self { + Self { + reason: reason.into(), + } + } + } + + /// Lowercase-hex encodes `bytes`. + #[must_use] + pub fn encode(bytes: &[u8]) -> String { + let mut hex = String::with_capacity(bytes.len() * 2); + for byte in bytes { + // Writing to a String is infallible. + let _ = write!(hex, "{byte:02x}"); + } + hex + } + + /// Decodes an even-length hex string, ignoring surrounding whitespace. + /// + /// Accepts either case on input; [`encode`] only ever emits lowercase. + /// + /// # Errors + /// Returns [`HexError`] when the input has an odd number of digits or + /// contains a character that is not a hex digit. + pub fn decode(input: &str) -> Result, HexError> { + let input = input.trim(); + if input.len() % 2 != 0 { + return Err(HexError::new("odd number of hex digits")); + } + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len() / 2); + let mut index = 0; + while index < bytes.len() { + let high = digit(bytes[index])?; + let low = digit(bytes[index + 1])?; + out.push((high << 4) | low); + index += 2; + } + Ok(out) + } + + fn digit(character: u8) -> Result { + match character { + b'0'..=b'9' => Ok(character - b'0'), + b'a'..=b'f' => Ok(character - b'a' + 10), + b'A'..=b'F' => Ok(character - b'A' + 10), + other => Err(HexError::new(format!( + "invalid hex digit '{}'", + char::from(other) + ))), + } + } +} + +/// Canonical JSON: the byte string a signature actually covers. +/// +/// Rules, and the reason each exists: +/// +/// - **Object keys sorted** by Unicode scalar value, so a re-serialization that +/// happens to emit fields in another order still verifies. +/// - **Compact output** (no insignificant whitespace), so pretty-printing a +/// file on disk does not invalidate its signature. +/// - **Arrays keep their order**, because array order is semantic. +/// - **Scalars use `serde_json`'s own encoding**, which already escapes strings +/// deterministically. Callers must not put floats in a signed structure; no +/// Andromeda signed type has one. +/// +/// Signers are expected to serialize the *typed* model rather than reuse the +/// bytes they parsed, so "field omitted" and "field written as `null`" cannot +/// produce two different messages for one logical value. +pub mod canonical_json { + use std::fmt::Write as _; + + /// Serializes `value` as canonical JSON into a new `String`. + #[must_use] + pub fn to_string(value: &serde_json::Value) -> String { + let mut out = String::new(); + write(value, &mut out); + out + } + + /// Recursively writes `value` as canonical JSON with sorted object keys. + pub fn write(value: &serde_json::Value, out: &mut String) { + match value { + serde_json::Value::Object(map) => { + out.push('{'); + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort_unstable(); + for (index, key) in keys.into_iter().enumerate() { + if index > 0 { + out.push(','); + } + write_string(key, out); + out.push(':'); + write(&map[key], out); + } + out.push('}'); + } + serde_json::Value::Array(items) => { + out.push('['); + for (index, item) in items.iter().enumerate() { + if index > 0 { + out.push(','); + } + write(item, out); + } + out.push(']'); + } + // Scalars: `Value`'s Display is compact JSON with correct escaping. + scalar => { + let _ = write!(out, "{scalar}"); + } + } + } + + /// Writes `text` as a JSON string literal (quoted and escaped). + fn write_string(text: &str, out: &mut String) { + let _ = write!(out, "{}", serde_json::Value::String(text.to_owned())); + } +} + +#[cfg(test)] +mod tests { + use super::{canonical_json, hex}; + + #[test] + fn hex_round_trips_and_rejects_bad_input() { + let bytes = [0x00u8, 0x0f, 0xa5, 0xff]; + assert_eq!(hex::encode(&bytes), "000fa5ff"); + assert_eq!(hex::decode("000fA5ff").unwrap(), bytes); + assert_eq!(hex::decode(" 000fa5ff \n").unwrap(), bytes); + assert!(hex::decode("abc").is_err()); // odd length + assert!(hex::decode("zz").is_err()); // non-hex digit + assert_eq!(hex::encode(&[]), ""); + assert_eq!(hex::decode("").unwrap(), Vec::::new()); + } + + #[test] + fn object_keys_are_sorted_regardless_of_input_order() { + let a: serde_json::Value = serde_json::from_str(r#"{"b":1,"a":2}"#).unwrap(); + let b: serde_json::Value = serde_json::from_str(r#"{"a":2,"b":1}"#).unwrap(); + assert_eq!(canonical_json::to_string(&a), r#"{"a":2,"b":1}"#); + assert_eq!(canonical_json::to_string(&a), canonical_json::to_string(&b)); + } + + #[test] + fn array_order_is_preserved() { + let value: serde_json::Value = serde_json::from_str("[3,1,2]").unwrap(); + assert_eq!(canonical_json::to_string(&value), "[3,1,2]"); + } + + #[test] + fn whitespace_and_nesting_do_not_change_the_bytes() { + let pretty: serde_json::Value = serde_json::from_str( + "{\n \"outer\": {\n \"z\": [1, {\"y\": null, \"x\": true}]\n }\n}", + ) + .unwrap(); + assert_eq!( + canonical_json::to_string(&pretty), + r#"{"outer":{"z":[1,{"x":true,"y":null}]}}"# + ); + } + + #[test] + fn strings_are_escaped_not_copied_raw() { + let value = serde_json::json!({ "quote\"key": "line\nbreak" }); + assert_eq!( + canonical_json::to_string(&value), + r#"{"quote\"key":"line\nbreak"}"# + ); + } +} diff --git a/crates/andromeda-core/src/lib.rs b/crates/andromeda-core/src/lib.rs index bffbf39..9f4f927 100644 --- a/crates/andromeda-core/src/lib.rs +++ b/crates/andromeda-core/src/lib.rs @@ -6,6 +6,8 @@ mod action; mod capability; +pub mod capability_signing; +pub mod encoding; mod task; pub use action::{ @@ -13,6 +15,11 @@ pub use action::{ PlanValidationError, RecoverySemantics, RiskLevel, }; pub use capability::{ - Capability, CapabilityId, CapabilityResource, FileAccess, IsolationLevel, normalized_absolute, + Capability, CapabilityId, CapabilityResource, CapabilitySignature, FileAccess, IsolationLevel, + normalized_absolute, +}; +pub use capability_signing::{ + CapabilityKeyring, CapabilitySignatureStatus, CapabilitySigningKey, SignatureError, + verify_capability_signature, }; pub use task::{Intent, TaskId, TaskState, TaskTransitionError}; diff --git a/crates/andromeda-hardware/Cargo.toml b/crates/andromeda-hardware/Cargo.toml index fb5a81d..a17670b 100644 --- a/crates/andromeda-hardware/Cargo.toml +++ b/crates/andromeda-hardware/Cargo.toml @@ -8,13 +8,18 @@ repository.workspace = true rust-version.workspace = true [dependencies] +# For `encoding::{canonical_json, hex}` only: the manifest and capability +# signature schemes must agree byte for byte on how a typed value becomes a +# message, so both call one implementation instead of keeping a copy each. +andromeda-core.workspace = true chrono.workspace = true -# Declared here (not in `[workspace.dependencies]`) because only this crate -# needs ed25519, for detached HCM manifest signature verification. Pure-Rust, -# no default `rand_core`/key-generation feature is enabled: the matcher only -# loads a fixed verifying key and verifies a detached signature, so no runtime -# RNG is ever invoked. `zeroize` keeps signing-key material from lingering in -# memory (used by the signing helper and future offline signing tooling). +# Declared here (matching `andromeda-core`'s declaration, so the two resolve to +# one `Cargo.lock` entry) because only these two crates need ed25519, for +# detached HCM manifest and capability signature verification. Pure-Rust, no +# default `rand_core`/key-generation feature is enabled: the matcher only loads +# a fixed verifying key and verifies a detached signature, so no runtime RNG is +# ever invoked. `zeroize` keeps signing-key material from lingering in memory +# (used by the signing helper and future offline signing tooling). ed25519-dalek = { version = "2.2.0", default-features = false, features = [ "std", "zeroize", diff --git a/crates/andromeda-hardware/src/signing.rs b/crates/andromeda-hardware/src/signing.rs index 9351b40..a110aa0 100644 --- a/crates/andromeda-hardware/src/signing.rs +++ b/crates/andromeda-hardware/src/signing.rs @@ -21,8 +21,8 @@ //! used by tests and by any future offline signing tool. use std::collections::BTreeMap; -use std::fmt::Write as _; +use andromeda_core::encoding::{canonical_json, hex}; use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; use crate::model::{HcmManifest, ManifestSignature}; @@ -265,11 +265,14 @@ pub fn verify_manifest_signature( /// - Serialize the *typed* manifest, not the raw file, so omitted vs explicit /// `null` and source formatting never change the bytes. /// - Remove the `signature` field (a signature cannot cover itself). -/// - Emit compact JSON with **every object's keys sorted** by Unicode scalar -/// value; arrays keep their order (arrays are semantically ordered). -/// - Scalars use `serde_json`'s own encoding (correct string escaping; -/// integers and booleans are already deterministic — the manifest has no -/// floats). +/// - Emit [canonical JSON](andromeda_core::encoding::canonical_json): compact, +/// with every object's keys sorted; arrays keep their (semantic) order. +/// +/// The JSON writer and the hex codec live in `andromeda-core` because the +/// capability signature scheme uses the same two encodings; keeping one +/// implementation is what stops the two schemes from drifting apart. The bytes +/// are unchanged from the local implementation this replaced, so signatures +/// issued against earlier revisions still verify. /// /// # Errors /// Returns [`SignatureError::Canonicalize`] if the manifest cannot be turned @@ -280,88 +283,17 @@ pub fn canonical_signing_bytes(manifest: &HcmManifest) -> Result, Signat if let Some(object) = value.as_object_mut() { object.remove("signature"); } - let mut canonical = String::new(); - write_canonical(&value, &mut canonical); - Ok(canonical.into_bytes()) -} - -/// Recursively writes `value` as canonical JSON with sorted object keys. -fn write_canonical(value: &serde_json::Value, out: &mut String) { - match value { - serde_json::Value::Object(map) => { - out.push('{'); - let mut keys: Vec<&String> = map.keys().collect(); - keys.sort_unstable(); - for (index, key) in keys.into_iter().enumerate() { - if index > 0 { - out.push(','); - } - write_json_string(key, out); - out.push(':'); - write_canonical(&map[key], out); - } - out.push('}'); - } - serde_json::Value::Array(items) => { - out.push('['); - for (index, item) in items.iter().enumerate() { - if index > 0 { - out.push(','); - } - write_canonical(item, out); - } - out.push(']'); - } - // Scalars: `Value`'s Display is compact JSON with correct escaping. - scalar => { - let _ = write!(out, "{scalar}"); - } - } -} - -/// Writes `text` as a JSON string literal (quoted and escaped). -fn write_json_string(text: &str, out: &mut String) { - let _ = write!(out, "{}", serde_json::Value::String(text.to_owned())); + Ok(canonical_json::to_string(&value).into_bytes()) } /// Lowercase-hex encodes `bytes`. fn hex_encode(bytes: &[u8]) -> String { - let mut hex = String::with_capacity(bytes.len() * 2); - for byte in bytes { - // Writing to a String is infallible. - let _ = write!(hex, "{byte:02x}"); - } - hex + hex::encode(bytes) } /// Decodes an even-length hex string, ignoring surrounding whitespace. fn hex_decode(input: &str) -> Result, SignatureError> { - let input = input.trim(); - if input.len() % 2 != 0 { - return Err(SignatureError::Hex("odd number of hex digits".to_owned())); - } - let bytes = input.as_bytes(); - let mut out = Vec::with_capacity(bytes.len() / 2); - let mut index = 0; - while index < bytes.len() { - let high = hex_value(bytes[index])?; - let low = hex_value(bytes[index + 1])?; - out.push((high << 4) | low); - index += 2; - } - Ok(out) -} - -fn hex_value(character: u8) -> Result { - match character { - b'0'..=b'9' => Ok(character - b'0'), - b'a'..=b'f' => Ok(character - b'a' + 10), - b'A'..=b'F' => Ok(character - b'A' + 10), - other => Err(SignatureError::Hex(format!( - "invalid hex digit '{}'", - char::from(other) - ))), - } + hex::decode(input).map_err(|error| SignatureError::Hex(error.to_string())) } #[cfg(test)] @@ -408,6 +340,23 @@ mod tests { .expect("manifest fixture") } + /// Golden vector. The canonical encoder moved to `andromeda-core` so the + /// manifest and capability schemes share one implementation; any future + /// edit to it that changes a byte would silently invalidate every manifest + /// signature already issued. Pinning the signature a fixed seed produces + /// over a fixed manifest turns that into a loud test failure. + #[test] + fn golden_signature_locks_the_canonical_bytes() { + let signature = signing_key() + .sign_manifest(&manifest(), "prod-2026") + .unwrap(); + assert_eq!( + signature.sig, + "85c8b42f0fc8306a6878be15515471f94b9ebdf89cd6ac6f5111bfc2a8ac3b86\ + 0bdb648ad5772fe9854ff4bcd80123bef2ad7ab77147329aee9aeab0708d4a01" + ); + } + #[test] fn seed_is_deterministic() { assert_eq!( diff --git a/crates/andromeda-policy/src/lib.rs b/crates/andromeda-policy/src/lib.rs index 65975dc..510befa 100644 --- a/crates/andromeda-policy/src/lib.rs +++ b/crates/andromeda-policy/src/lib.rs @@ -449,6 +449,7 @@ mod tests { issued_at: Utc::now(), expires_at: None, single_use: false, + signature: None, } } @@ -534,6 +535,7 @@ mod tests { issued_at: Utc::now(), expires_at: None, single_use: true, + signature: None, }; let action = action( ActionKind::ExternalCall, @@ -560,6 +562,7 @@ mod tests { issued_at: Utc::now(), expires_at: None, single_use: false, + signature: None, } } @@ -875,6 +878,7 @@ mod tests { issued_at: Utc::now(), expires_at: None, single_use: false, + signature: None, }; let action = action( ActionKind::ExternalCall, @@ -905,6 +909,7 @@ mod tests { issued_at: Utc::now(), expires_at: None, single_use: false, + signature: None, }; let action = action( ActionKind::ExternalCall, diff --git a/crates/andromeda-runtime/src/admission.rs b/crates/andromeda-runtime/src/admission.rs new file mode 100644 index 0000000..b7e89af --- /dev/null +++ b/crates/andromeda-runtime/src/admission.rs @@ -0,0 +1,296 @@ +//! Whether a capability is allowed into a task at all. +//! +//! `andromeda-core` can tell you whether a capability carries a signature that +//! a trusted issuer produced. This module decides what the control plane does +//! with that answer, and it makes the decision *explicit*: [`CapabilityAdmission`] +//! has no `Default`, so every construction of a `TaskService` has to name the +//! policy it is running under. There is no way to end up unsigned by omission. + +use andromeda_core::{ + Capability, CapabilityId, CapabilityKeyring, SignatureError, verify_capability_signature, +}; + +/// The policy a [`TaskService`](crate::TaskService) applies to incoming +/// capabilities. +/// +/// # Why this is configuration rather than a hard requirement +/// +/// Nothing in this repository *issues* capabilities yet. Rejecting unsigned +/// grants unconditionally would therefore reject every request any current +/// client can make, and orphan every task record already on disk — a change +/// that fails closed so hard it simply removes the feature. Instead the +/// mechanism ships now and the enforcement is a deployment decision, so an +/// operator who has an issuer can turn it on today and the shipped image can +/// state honestly which mode it runs in. +/// +/// Both variants are spelled out at every call site, which is the point: the +/// weak one is named [`CapabilityAdmission::unsigned_for_development`] and +/// cannot be selected by forgetting an argument. +#[derive(Debug, Clone)] +pub enum CapabilityAdmission { + /// Accept capabilities with or without a signature. + /// + /// This is the v0 development posture and it is **not** a security + /// boundary: a caller mints its own grants, exactly as described in + /// `docs/reviews/security-review.md` finding #3. Any signature that *is* + /// present is left untouched and unverified — a verified-looking record + /// under this mode means nothing. + UnsignedAllowed, + /// Require every capability to carry a signature that verifies against a + /// key in the keyring. Unsigned, unknown-key, malformed, and tampered + /// grants are all rejected. + RequireSigned(Box), +} + +impl CapabilityAdmission { + /// The development posture: unsigned capabilities are accepted. + /// + /// Named for what it costs, not for what it does, so that reading a call + /// site tells you the deployment is unprotected. + #[must_use] + pub const fn unsigned_for_development() -> Self { + Self::UnsignedAllowed + } + + /// Require issuer signatures against `keyring`. + /// + /// # Errors + /// Returns [`SignatureError::EmptyKeyring`] when `keyring` holds no keys. + /// An empty keyring would reject every request while presenting as a + /// hardened configuration; refusing to build it turns a typo in a + /// trusted-keys file into a startup failure with a reason. + pub fn require_signed(keyring: CapabilityKeyring) -> Result { + Ok(Self::RequireSigned(Box::new(keyring.require_non_empty()?))) + } + + /// Whether signatures are enforced. Reported on `/healthz` so the posture + /// of a running daemon is observable from outside it. + #[must_use] + pub const fn requires_signatures(&self) -> bool { + matches!(self, Self::RequireSigned(_)) + } + + /// A stable name for logs, `/healthz`, and documentation. + #[must_use] + pub const fn mode_name(&self) -> &'static str { + match self { + Self::UnsignedAllowed => "unsigned_allowed", + Self::RequireSigned(_) => "require_signed", + } + } + + /// Checks every capability in `capabilities`, returning the first rejection. + /// + /// # Length bound + /// + /// Ed25519 verification is deliberately expensive, so running it over a + /// caller-supplied vector of unbounded length is a local denial of service + /// (the flaw recorded as `remediation-design-review.md` §1 item 6, bounded + /// by PR #24). This function is therefore **only** called after the caller + /// has enforced `MAX_TASK_CAPABILITIES`; it re-states that requirement in + /// [`AdmissionError::Unbounded`] rather than trusting it, so a future call + /// site that forgets gets an error instead of an unbounded verification + /// loop. + /// + /// # Errors + /// Returns [`AdmissionError`] for the first capability that is not + /// admissible under this policy, or if `capabilities` exceeds `limit`. + pub fn admit(&self, capabilities: &[Capability], limit: usize) -> Result<(), AdmissionError> { + if capabilities.len() > limit { + return Err(AdmissionError::Unbounded { + capabilities: capabilities.len(), + limit, + }); + } + let Self::RequireSigned(keyring) = self else { + return Ok(()); + }; + for capability in capabilities { + let status = verify_capability_signature(capability, keyring); + if let Some(reason) = status.rejection_reason() { + return Err(AdmissionError::Rejected { + capability: capability.id, + reason, + }); + } + } + Ok(()) + } +} + +/// A capability was refused before it could be attached to a task. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum AdmissionError { + #[error("capability {capability} was not admitted: {reason}")] + Rejected { + capability: CapabilityId, + reason: String, + }, + /// The caller handed in more capabilities than the bound allows. Reaching + /// this means a call site skipped its own length check; it is a programming + /// error surfaced as a refusal rather than as unbounded work. + #[error( + "refusing to verify {capabilities} capabilities, which exceeds the limit of {limit}; \ + the length bound must be enforced before any signature check" + )] + Unbounded { capabilities: usize, limit: usize }, +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use andromeda_core::{ + CapabilityResource, CapabilitySignature, CapabilitySigningKey, FileAccess, + }; + use chrono::{TimeZone as _, Utc}; + + use super::*; + + /// Fixed seed, mirroring `andromeda-hardware`: no test may depend on an RNG. + const SEED: [u8; 32] = [7u8; 32]; + const OTHER_SEED: [u8; 32] = [8u8; 32]; + const KEY_ID: &str = "issuer-2026"; + + fn signing_key() -> CapabilitySigningKey { + CapabilitySigningKey::from_seed(&SEED) + } + + fn keyring() -> CapabilityKeyring { + let mut keyring = CapabilityKeyring::new(); + keyring + .insert_hex(KEY_ID, &signing_key().verifying_key_hex()) + .expect("valid key hex"); + keyring + } + + fn admission() -> CapabilityAdmission { + CapabilityAdmission::require_signed(keyring()).expect("non-empty keyring") + } + + fn capability() -> Capability { + Capability { + id: CapabilityId::new(), + resource: CapabilityResource::Files { + root: PathBuf::from("/work/project"), + access: FileAccess::Read, + }, + issued_to: "task".to_owned(), + issued_at: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(), + expires_at: None, + single_use: false, + signature: None, + } + } + + fn signed_capability() -> Capability { + let mut capability = capability(); + signing_key() + .sign_in_place(&mut capability, KEY_ID) + .expect("sign"); + capability + } + + #[test] + fn a_valid_signed_capability_is_admitted() { + assert_eq!(admission().admit(&[signed_capability()], 10), Ok(())); + } + + #[test] + fn a_tampered_capability_is_rejected() { + let mut capability = signed_capability(); + capability.resource = CapabilityResource::Files { + root: PathBuf::from("/"), + access: FileAccess::ReadWrite, + }; + let error = admission().admit(&[capability], 10).unwrap_err(); + assert!( + matches!(&error, AdmissionError::Rejected { reason, .. } if reason.contains("did not verify")), + "{error}" + ); + } + + #[test] + fn an_unknown_key_is_rejected() { + let mut capability = capability(); + CapabilitySigningKey::from_seed(&OTHER_SEED) + .sign_in_place(&mut capability, "rogue") + .expect("sign"); + let error = admission().admit(&[capability], 10).unwrap_err(); + assert!( + matches!(&error, AdmissionError::Rejected { reason, .. } if reason.contains("not in the keyring")), + "{error}" + ); + } + + #[test] + fn an_unsigned_capability_is_rejected_when_signatures_are_required() { + let error = admission().admit(&[capability()], 10).unwrap_err(); + assert!( + matches!(&error, AdmissionError::Rejected { reason, .. } if reason.contains("no issuer signature")), + "{error}" + ); + } + + #[test] + fn a_malformed_signature_is_rejected() { + let mut capability = capability(); + capability.signature = Some(CapabilitySignature { + key_id: KEY_ID.to_owned(), + sig: "not-hex".to_owned(), + }); + let error = admission().admit(&[capability], 10).unwrap_err(); + assert!( + matches!(&error, AdmissionError::Rejected { reason, .. } if reason.contains("malformed")), + "{error}" + ); + } + + /// Constraint: the length bound must come *before* any cryptography. The + /// list here is both over the limit and full of grants that would each fail + /// verification, so the error identifies which check ran first. + #[test] + fn the_length_bound_is_enforced_before_any_verification() { + let limit = 4; + let capabilities: Vec = std::iter::repeat_n(capability(), limit + 1).collect(); + assert_eq!( + admission().admit(&capabilities, limit), + Err(AdmissionError::Unbounded { + capabilities: limit + 1, + limit, + }) + ); + // Exactly at the limit, the (failing) verification is what reports. + let at_limit: Vec = std::iter::repeat_n(capability(), limit).collect(); + assert!(matches!( + admission().admit(&at_limit, limit), + Err(AdmissionError::Rejected { .. }) + )); + } + + /// The bound applies even in the permissive mode, so a call site cannot use + /// "signatures are off today" to smuggle in an unbounded vector. + #[test] + fn the_length_bound_applies_in_unsigned_mode_too() { + let admission = CapabilityAdmission::unsigned_for_development(); + assert!(matches!( + admission.admit(&[capability(), capability()], 1), + Err(AdmissionError::Unbounded { .. }) + )); + assert_eq!(admission.admit(&[capability()], 1), Ok(())); + } + + #[test] + fn an_empty_keyring_cannot_be_configured() { + assert!(CapabilityAdmission::require_signed(CapabilityKeyring::new()).is_err()); + } + + #[test] + fn modes_report_themselves() { + assert!(admission().requires_signatures()); + assert_eq!(admission().mode_name(), "require_signed"); + let development = CapabilityAdmission::unsigned_for_development(); + assert!(!development.requires_signatures()); + assert_eq!(development.mode_name(), "unsigned_allowed"); + } +} diff --git a/crates/andromeda-runtime/src/lib.rs b/crates/andromeda-runtime/src/lib.rs index cc35019..abc9a09 100644 --- a/crates/andromeda-runtime/src/lib.rs +++ b/crates/andromeda-runtime/src/lib.rs @@ -4,9 +4,11 @@ //! deliberately does not execute model-proposed tools. Executors are added //! behind separately attested isolation and broker interfaces. +mod admission; mod service; mod store; +pub use admission::{AdmissionError, CapabilityAdmission}; pub use service::{ CreateTaskRequest, EvaluationReport, EvaluationRequest, GrantCapabilitiesRequest, MAX_PLAN_ACTIONS, MAX_TASK_CAPABILITIES, RecordOutcomeRequest, ServiceError, diff --git a/crates/andromeda-runtime/src/service.rs b/crates/andromeda-runtime/src/service.rs index 8361823..1934fbb 100644 --- a/crates/andromeda-runtime/src/service.rs +++ b/crates/andromeda-runtime/src/service.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use uuid::Uuid; +use crate::admission::{AdmissionError, CapabilityAdmission}; use crate::store::TaskListing; use crate::{FileTaskStore, StoreError}; @@ -308,18 +309,42 @@ pub enum ServiceError { Transition(#[from] andromeda_core::TaskTransitionError), #[error(transparent)] Guard(#[from] TransitionGuardError), + /// A capability was refused by the configured [`CapabilityAdmission`]. + #[error(transparent)] + Admission(#[from] AdmissionError), } #[derive(Debug, Clone)] pub struct TaskService { store: FileTaskStore, policy: PolicyEngine, + admission: CapabilityAdmission, } impl TaskService { + /// Builds a service. + /// + /// `admission` is a required argument on purpose: whether the control + /// plane accepts self-minted capabilities is a security posture, and a + /// posture that can be reached by leaving an argument off is one nobody + /// chose. [`CapabilityAdmission`] has no `Default` for the same reason. + #[must_use] + pub const fn new( + store: FileTaskStore, + policy: PolicyEngine, + admission: CapabilityAdmission, + ) -> Self { + Self { + store, + policy, + admission, + } + } + + /// The capability admission policy in force, for `/healthz` and logs. #[must_use] - pub const fn new(store: FileTaskStore, policy: PolicyEngine) -> Self { - Self { store, policy } + pub const fn admission(&self) -> &CapabilityAdmission { + &self.admission } /// Validates and durably creates a task. @@ -329,7 +354,13 @@ impl TaskService { /// Returns validation errors for malformed/untrusted plans and store errors /// for persistence failures. pub fn create(&self, request: CreateTaskRequest) -> Result { + // Order matters and is load-bearing: `validate_plan` enforces + // MAX_TASK_CAPABILITIES, so by the time `admit` runs the vector it + // verifies is already bounded. Signature verification must never be + // reachable with an unbounded input. validate_plan(&request.plan, &request.capabilities)?; + self.admission + .admit(&request.capabilities, MAX_TASK_CAPABILITIES)?; let state = if self.plan_fully_granted(&request.plan, &request.capabilities) { TaskState::Ready } else { @@ -490,6 +521,11 @@ impl TaskService { } .into()); } + // The bound above covers this request too: `total` includes + // `request.capabilities.len()`, so a request over the limit is rejected + // before any signature is verified. Same ordering rule as `create`. + self.admission + .admit(&request.capabilities, MAX_TASK_CAPABILITIES)?; let now = Utc::now(); let expected_subject = record.plan.task_id.to_string(); for capability in &request.capabilities { @@ -863,6 +899,7 @@ mod tests { issued_at: Utc::now(), expires_at: None, single_use: false, + signature: None, }; let plan = ActionPlan { schema_version: ActionPlan::CURRENT_SCHEMA_VERSION, @@ -888,9 +925,14 @@ mod tests { } fn service(temp: &TempDir) -> TaskService { + service_with(temp, CapabilityAdmission::unsigned_for_development()) + } + + fn service_with(temp: &TempDir, admission: CapabilityAdmission) -> TaskService { TaskService::new( FileTaskStore::open(temp.path()).expect("store"), PolicyEngine::new(PolicySet::default()), + admission, ) } @@ -1096,6 +1138,131 @@ mod tests { assert_eq!(validate_plan(&request.plan, &capabilities), Ok(())); } + /// Fixed seed: the runtime's admission tests must be reproducible, so no + /// key material comes from an RNG. + const ISSUER_SEED: [u8; 32] = [5u8; 32]; + const ISSUER_KEY_ID: &str = "issuer-2026"; + + fn issuer() -> andromeda_core::CapabilitySigningKey { + andromeda_core::CapabilitySigningKey::from_seed(&ISSUER_SEED) + } + + fn signing_admission() -> CapabilityAdmission { + let mut keyring = andromeda_core::CapabilityKeyring::new(); + keyring + .insert_hex(ISSUER_KEY_ID, &issuer().verifying_key_hex()) + .expect("valid key"); + CapabilityAdmission::require_signed(keyring).expect("non-empty keyring") + } + + #[test] + fn create_accepts_a_capability_the_trusted_issuer_signed() { + let temp = TempDir::new().expect("tempdir"); + let service = service_with(&temp, signing_admission()); + let mut request = inspection_request(workspace_path()); + issuer() + .sign_in_place(&mut request.capabilities[0], ISSUER_KEY_ID) + .expect("sign"); + let record = service.create(request).expect("signed capability admitted"); + assert_eq!(record.state, TaskState::Ready); + } + + #[test] + fn create_refuses_an_unsigned_capability_when_signatures_are_required() { + let temp = TempDir::new().expect("tempdir"); + let service = service_with(&temp, signing_admission()); + let error = service + .create(inspection_request(workspace_path())) + .expect_err("unsigned capability must be refused"); + assert!( + matches!( + error, + ServiceError::Admission(AdmissionError::Rejected { .. }) + ), + "{error:?}" + ); + } + + #[test] + fn create_refuses_a_capability_tampered_with_after_signing() { + let temp = TempDir::new().expect("tempdir"); + let service = service_with(&temp, signing_admission()); + let mut request = inspection_request(workspace_path()); + issuer() + .sign_in_place(&mut request.capabilities[0], ISSUER_KEY_ID) + .expect("sign"); + // Widen the grant to the filesystem root after the issuer vouched for + // a single directory — the exact escalation the signature exists to + // stop. + request.capabilities[0].resource = CapabilityResource::Files { + root: PathBuf::from(outside_path()), + access: FileAccess::ReadWrite, + }; + let error = service + .create(request) + .expect_err("tampering must be caught"); + assert!( + matches!( + error, + ServiceError::Admission(AdmissionError::Rejected { .. }) + ), + "{error:?}" + ); + } + + #[test] + fn grant_refuses_an_unsigned_capability_when_signatures_are_required() { + let temp = TempDir::new().expect("tempdir"); + let service = service_with(&temp, signing_admission()); + let mut request = inspection_request(workspace_path()); + let unsigned = request.capabilities[0].clone(); + issuer() + .sign_in_place(&mut request.capabilities[0], ISSUER_KEY_ID) + .expect("sign"); + let created = service.create(request).expect("create"); + let error = service + .grant_capabilities( + created.plan.task_id, + GrantCapabilitiesRequest { + capabilities: vec![unsigned], + actor: "caller".into(), + expected_revision: created.revision, + }, + ) + .expect_err("the grant path must enforce the same rule as create"); + assert!( + matches!( + error, + ServiceError::Admission(AdmissionError::Rejected { .. }) + ), + "{error:?}" + ); + } + + /// The bound must be enforced *before* any signature is verified, or an + /// unauthenticated-shaped request could force unbounded ed25519 work. The + /// capabilities here are both over the limit and individually inadmissible, + /// so the error names whichever check ran first — and it must be the bound. + #[test] + fn the_capability_bound_is_enforced_before_signature_verification() { + let temp = TempDir::new().expect("tempdir"); + let service = service_with(&temp, signing_admission()); + let mut request = inspection_request(workspace_path()); + let capability = request.capabilities[0].clone(); + request.capabilities = std::iter::repeat_n(capability, MAX_TASK_CAPABILITIES + 1).collect(); + let error = service.create(request).expect_err("must be rejected"); + assert!( + matches!( + error, + ServiceError::Validation(ValidationError::TooManyCapabilities { + capabilities: c, + limit: MAX_TASK_CAPABILITIES, + }) if c == MAX_TASK_CAPABILITIES + 1 + ), + "the length bound must reject before verification, got {error:?}" + ); + } + #[test] fn repeated_grants_cannot_walk_past_the_capability_limit() { // The bound is on the resulting total, so a sequence of individually @@ -1238,6 +1405,7 @@ mod tests { issued_at: Utc::now(), expires_at: None, single_use: false, + signature: None, }; let network_cap = Capability { id: CapabilityId::new(), @@ -1249,6 +1417,7 @@ mod tests { issued_at: Utc::now(), expires_at: None, single_use: false, + signature: None, }; let parse = ActionSpec { id: ActionId::new(), diff --git a/crates/andromeda-taskd/Cargo.toml b/crates/andromeda-taskd/Cargo.toml index a2dfa05..fce02a0 100644 --- a/crates/andromeda-taskd/Cargo.toml +++ b/crates/andromeda-taskd/Cargo.toml @@ -15,9 +15,16 @@ axum.workspace = true clap.workspace = true serde.workspace = true serde_json.workspace = true +thiserror.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true +# For `Uuid::new_v4` as the CSPRNG behind the generated API token and the +# temporary-file suffix used to install it atomically. Already the workspace's +# randomness source (capability ids, event ids), so this adds no lockfile entry +# — and specifically avoids a crate that would drag in a third major version of +# `getrandom`. +uuid.workspace = true [dev-dependencies] chrono.workspace = true diff --git a/crates/andromeda-taskd/src/auth.rs b/crates/andromeda-taskd/src/auth.rs new file mode 100644 index 0000000..547be5b --- /dev/null +++ b/crates/andromeda-taskd/src/auth.rs @@ -0,0 +1,487 @@ +//! Local caller authentication for `taskd`, and the one place that decides +//! how the token is protected on disk. +//! +//! # The guarantee, and where it is enforced +//! +//! [`app`](crate::app) takes an [`Authenticator`] by value. `Authenticator` has +//! no `Default`, no public fields, and no constructor that yields "accept +//! everything" — so *there is no way to spell an unauthenticated router*. The +//! guarantee lives in the type that wires the service to the transport, not in +//! a configuration check that some other path could skip. An earlier design was +//! rejected precisely for validating an `AuthConfig` while leaving anonymous +//! listening representable; see `docs/reviews/remediation-design-review.md` §1. +//! +//! # Identity and permissions: decided once, here +//! +//! The previous attempt created a key directory as `0700 root:root` in two +//! separate places while the unit ran the service under `DynamicUser=` — so +//! `taskd` failed at startup, and a `UMask=0077` made the files unreadable to +//! the group that was supposed to read them. The fix is not to be more careful +//! in two places; it is to have one. +//! +//! This module is that place. The constants below define the protection the +//! token directory and file must have, [`ensure_private`] asserts it at +//! startup, and `unit_matches_token_constants` (in `crate::tests`) asserts that +//! the shipped `andromeda-taskd.service` agrees with them. The unit declares a +//! `RuntimeDirectory=`; it never states an owner, and nothing else in the tree +//! creates the directory. +//! +//! The resulting model, stated plainly: +//! +//! - The service's runtime identity is **whatever systemd assigns it** +//! (`DynamicUser=yes`). No file in this repository names a uid or gid, so +//! there is nothing to keep in sync and nothing to get wrong. +//! - `RuntimeDirectory=` is created by systemd owned by that identity, mode +//! `0700`. The token file inside is `0600`. Both are asserted, not assumed. +//! - Therefore the API is reachable by the service account and by root, and by +//! nobody else — including other local users, which is the exposure security +//! review finding #2 records. An operator drives the API as root +//! (`Authorization: Bearer $(sudo cat /run/andromeda-taskd/token)`). +//! +//! # Why a bearer token rather than `SO_PEERCRED` +//! +//! Peer credentials are the stronger primitive, but they need an `AF_UNIX` +//! listener, and the shipped unit serves loopback TCP. A token over a +//! `0700` runtime directory delivers the same practical boundary — the +//! filesystem answers "is this caller the service account or root?" — with a +//! mechanism that is enforced identically on every path `taskd` can be started +//! from, including a developer running it by hand. A `AF_UNIX` transport can be +//! added later as a second [`Authenticator`] variant without weakening this +//! one, because the type admits no unauthenticated variant to fall back to. + +use std::fs::{File, OpenOptions}; +use std::io::Write as _; +use std::path::{Path, PathBuf}; + +use andromeda_core::encoding::hex; + +/// Bytes of entropy in a generated token (256 bits, hex-encoded to 64 chars). +pub const TOKEN_BYTES: usize = 32; + +/// Shortest token accepted from an operator-supplied file. +/// +/// A caller may bring its own token, but not a guessable one. 32 characters is +/// the floor; generated tokens are 64 hex characters. +pub const MIN_TOKEN_CHARS: usize = 32; + +/// Mode the token file is created with, and required to have. +pub const TOKEN_FILE_MODE: u32 = 0o600; + +/// Mode the directory holding the token must have, and the value the shipped +/// unit's `RuntimeDirectoryMode=`/`StateDirectoryMode=` must carry. +pub const TOKEN_DIR_MODE: u32 = 0o700; + +/// Permission bits that must be clear on the token file and its directory: +/// no group or other access of any kind. +pub const PRIVATE_MODE_MASK: u32 = 0o077; + +/// `RuntimeDirectory=` in the shipped unit; the token lives inside it. +pub const RUNTIME_DIRECTORY_NAME: &str = "andromeda-taskd"; + +/// Absolute token path the shipped unit points `ANDROMEDA_AUTH_TOKEN_FILE` at. +pub const SYSTEM_TOKEN_PATH: &str = "/run/andromeda-taskd/token"; + +/// A token file, or the directory holding it, could not be used safely. +/// +/// Every variant is fatal at startup: `taskd` will not serve without a token +/// it trusts, and there is no degraded mode to fall back to. +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + #[error( + "token directory {} does not exist; systemd creates it from RuntimeDirectory=, so \ + either start taskd through andromeda-taskd.service or create the directory yourself \ + with mode {:04o}", + .0.display(), + TOKEN_DIR_MODE + )] + MissingDirectory(PathBuf), + #[error("token path {} has no parent directory", .0.display())] + NoParent(PathBuf), + #[error( + "{} is mode {:04o}, which grants access beyond its owner; the API token must be \ + readable only by the service account (directory {:04o}, file {:04o})", + .path.display(), + .mode, + TOKEN_DIR_MODE, + TOKEN_FILE_MODE + )] + TooPermissive { path: PathBuf, mode: u32 }, + #[error( + "token in {} is {found} characters; at least {MIN_TOKEN_CHARS} are required so the \ + API cannot be reached by guessing", + .path.display() + )] + TokenTooShort { path: PathBuf, found: usize }, + #[error("could not {action} {}: {source}", .path.display())] + Io { + action: &'static str, + path: PathBuf, + #[source] + source: std::io::Error, + }, +} + +impl AuthError { + fn io(action: &'static str, path: &Path, source: std::io::Error) -> Self { + Self::Io { + action, + path: path.to_path_buf(), + source, + } + } +} + +/// Proof that a request came from a caller who holds the local API token. +/// +/// The only inhabitant of this type is a real secret. There is deliberately no +/// `Anonymous`, `Disabled`, or `None` variant, and no `Default`: an +/// unauthenticated listener is not representable, so it cannot be reached by a +/// missing flag, a mis-parsed config, or a code path that forgot to check. +#[derive(Clone)] +pub struct Authenticator { + secret: Vec, +} + +// The token must never reach a log line or an error message. +impl std::fmt::Debug for Authenticator { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Authenticator") + .field("secret", &"") + .finish() + } +} + +impl Authenticator { + /// Builds an authenticator from an in-memory token. + /// + /// # Errors + /// Returns [`AuthError::TokenTooShort`] when the token has fewer than + /// [`MIN_TOKEN_CHARS`] characters after trimming — including the empty + /// string, which is how "no authentication" would otherwise sneak in. + pub fn from_token(token: &str) -> Result { + Self::from_token_at(token, Path::new("")) + } + + fn from_token_at(token: &str, path: &Path) -> Result { + let token = token.trim(); + if token.chars().count() < MIN_TOKEN_CHARS { + return Err(AuthError::TokenTooShort { + path: path.to_path_buf(), + found: token.chars().count(), + }); + } + Ok(Self { + secret: token.as_bytes().to_vec(), + }) + } + + /// Loads the token from `path`, generating one if the file is absent. + /// + /// The directory must already exist and be private; `taskd` does not create + /// it, because on the shipped image systemd does (`RuntimeDirectory=`) and + /// two creators is exactly the bug this design exists to avoid. A generated + /// token is written atomically with mode [`TOKEN_FILE_MODE`]. + /// + /// # Errors + /// Returns [`AuthError`] when the directory is missing or too permissive, + /// the existing token file is too permissive or too short, or any file + /// operation fails. + pub fn from_token_file(path: &Path) -> Result { + let directory = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .ok_or_else(|| AuthError::NoParent(path.to_path_buf()))?; + if !directory.is_dir() { + return Err(AuthError::MissingDirectory(directory.to_path_buf())); + } + ensure_private(directory)?; + + if !path.exists() { + write_new_token(path)?; + } + ensure_private(path)?; + let contents = + std::fs::read_to_string(path).map_err(|error| AuthError::io("read", path, error))?; + Self::from_token_at(&contents, path) + } + + /// Whether `candidate` is the token, compared in constant time. + /// + /// The comparison must not return early on the first differing byte: a + /// local attacker can time thousands of requests and recover the token byte + /// by byte otherwise. Length is compared first and does leak, which is + /// harmless — the token length is fixed and documented. + #[must_use] + pub fn matches(&self, candidate: &[u8]) -> bool { + if candidate.len() != self.secret.len() { + return false; + } + let mut difference = 0u8; + for (left, right) in self.secret.iter().zip(candidate) { + difference |= left ^ right; + } + // `black_box` keeps the accumulate-then-compare shape from being + // rewritten into an early-exit loop by a future optimizer. + std::hint::black_box(difference) == 0 + } +} + +/// Asserts that `path` grants no access beyond its owner. +/// +/// This is the assertion the rework constraint asks for: the permission model +/// is stated once (the constants above) and checked at startup, so a +/// mis-declared unit directive fails loudly with a reason instead of leaving +/// the token readable. +/// +/// On non-Unix targets there are no mode bits to check and this is a no-op; +/// `taskd` ships only on Linux, and the Windows CI job builds and tests the +/// crate rather than deploying it. +/// +/// # Errors +/// Returns [`AuthError::TooPermissive`] when any bit in [`PRIVATE_MODE_MASK`] +/// is set, or an IO error when the metadata cannot be read. +pub fn ensure_private(path: &Path) -> Result<(), AuthError> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + + let metadata = std::fs::metadata(path) + .map_err(|error| AuthError::io("read metadata for", path, error))?; + let mode = metadata.permissions().mode() & 0o777; + if mode & PRIVATE_MODE_MASK != 0 { + return Err(AuthError::TooPermissive { + path: path.to_path_buf(), + mode, + }); + } + } + #[cfg(not(unix))] + { + let _ = std::fs::metadata(path) + .map_err(|error| AuthError::io("read metadata for", path, error))?; + } + Ok(()) +} + +/// Generates a token and writes it to `path` atomically with +/// [`TOKEN_FILE_MODE`]. +fn write_new_token(path: &Path) -> Result<(), AuthError> { + let token = generate_token(); + let temporary = path.with_extension(format!("{}.tmp", uuid::Uuid::new_v4())); + let mut file = create_private(&temporary)?; + file.write_all(token.as_bytes()) + .map_err(|error| AuthError::io("write", &temporary, error))?; + file.sync_all() + .map_err(|error| AuthError::io("sync", &temporary, error))?; + drop(file); + std::fs::rename(&temporary, path).map_err(|error| { + // Leaving a readable temp file behind would defeat the whole point. + let _ = std::fs::remove_file(&temporary); + AuthError::io("install", path, error) + }) +} + +/// Creates a new file that only its owner can read, failing if it exists. +fn create_private(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + // Set at creation, not with a later chmod: a chmod leaves a window in + // which the token exists at the umask's mode. + options.mode(TOKEN_FILE_MODE); + } + options + .open(path) + .map_err(|error| AuthError::io("create", path, error)) +} + +/// Returns [`TOKEN_BYTES`] of CSPRNG output, hex encoded. +/// +/// The randomness comes from `uuid`'s v4 generator, which is backed by +/// `getrandom` — already in this workspace's dependency graph and already the +/// source of every `CapabilityId` and audit event id. Pulling in a crate that +/// depends on `getrandom 0.3.x` would put a *third* major version of it in +/// `Cargo.lock` (0.2.17 and 0.4.3 are both present), which is exactly the +/// gratuitous supply-chain growth the rework constraints forbid. +/// +/// Two v4 UUIDs supply 244 bits of CSPRNG output; the six version/variant bits +/// are fixed and are simply discarded here rather than counted as entropy. +fn generate_token() -> String { + let mut bytes = Vec::with_capacity(TOKEN_BYTES); + while bytes.len() < TOKEN_BYTES { + bytes.extend_from_slice(uuid::Uuid::new_v4().as_bytes()); + } + bytes.truncate(TOKEN_BYTES); + hex::encode(&bytes) +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + /// Creates a private directory the way systemd's `RuntimeDirectory=` would. + fn private_dir() -> TempDir { + let temp = TempDir::new().expect("tempdir"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(TOKEN_DIR_MODE)) + .expect("tighten tempdir"); + } + temp + } + + #[test] + fn a_generated_token_is_hex_and_full_length() { + let token = generate_token(); + assert_eq!(token.len(), TOKEN_BYTES * 2); + assert!(token.bytes().all(|byte| byte.is_ascii_hexdigit())); + assert!(token.len() >= MIN_TOKEN_CHARS); + // Two calls must not collide; a constant token would authenticate + // every installation to every other one. + assert_ne!(token, generate_token()); + } + + #[test] + fn the_token_file_is_created_private_and_reused() { + let temp = private_dir(); + let path = temp.path().join("token"); + let first = Authenticator::from_token_file(&path).expect("create token"); + assert!(path.exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, TOKEN_FILE_MODE, "got {mode:04o}"); + } + // A restart must keep the same token, or every restart would silently + // lock out whoever holds the old one. + let contents = std::fs::read_to_string(&path).unwrap(); + assert!(first.matches(contents.trim().as_bytes())); + let second = Authenticator::from_token_file(&path).expect("reload token"); + assert!(second.matches(contents.trim().as_bytes())); + } + + #[test] + fn no_temporary_files_are_left_behind() { + let temp = private_dir(); + let path = temp.path().join("token"); + Authenticator::from_token_file(&path).expect("create token"); + let leftovers: Vec<_> = std::fs::read_dir(temp.path()) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_name() != "token") + .map(|entry| entry.file_name()) + .collect(); + assert!(leftovers.is_empty(), "{leftovers:?}"); + } + + #[test] + fn a_missing_directory_is_a_startup_error() { + let temp = private_dir(); + let path = temp.path().join("absent").join("token"); + assert!(matches!( + Authenticator::from_token_file(&path), + Err(AuthError::MissingDirectory(_)) + )); + } + + #[cfg(unix)] + #[test] + fn a_group_readable_directory_is_refused() { + use std::os::unix::fs::PermissionsExt as _; + + let temp = private_dir(); + std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o750)) + .expect("loosen tempdir"); + let path = temp.path().join("token"); + let error = Authenticator::from_token_file(&path).unwrap_err(); + assert!( + matches!(error, AuthError::TooPermissive { .. }), + "{error:?}" + ); + } + + #[cfg(unix)] + #[test] + fn a_world_readable_token_file_is_refused() { + use std::os::unix::fs::PermissionsExt as _; + + let temp = private_dir(); + let path = temp.path().join("token"); + std::fs::write(&path, "0".repeat(MIN_TOKEN_CHARS)).expect("write token"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)) + .expect("loosen token"); + let error = Authenticator::from_token_file(&path).unwrap_err(); + assert!( + matches!(error, AuthError::TooPermissive { .. }), + "{error:?}" + ); + } + + #[test] + fn an_empty_or_short_token_cannot_build_an_authenticator() { + assert!(matches!( + Authenticator::from_token(""), + Err(AuthError::TokenTooShort { found: 0, .. }) + )); + assert!(matches!( + Authenticator::from_token(" \n "), + Err(AuthError::TokenTooShort { found: 0, .. }) + )); + assert!(Authenticator::from_token(&"a".repeat(MIN_TOKEN_CHARS - 1)).is_err()); + assert!(Authenticator::from_token(&"a".repeat(MIN_TOKEN_CHARS)).is_ok()); + } + + #[test] + fn a_short_token_file_is_refused_rather_than_padded() { + let temp = private_dir(); + let path = temp.path().join("token"); + let mut file = create_private(&path).expect("create"); + file.write_all(b"tooshort").expect("write"); + drop(file); + assert!(matches!( + Authenticator::from_token_file(&path), + Err(AuthError::TokenTooShort { .. }) + )); + } + + #[test] + fn matching_is_exact() { + let token = "a".repeat(MIN_TOKEN_CHARS); + let authenticator = Authenticator::from_token(&token).expect("token"); + assert!(authenticator.matches(token.as_bytes())); + assert!(!authenticator.matches(b"")); + assert!(!authenticator.matches(format!("{token}x").as_bytes())); + assert!(!authenticator.matches(token[..token.len() - 1].as_bytes())); + let mut wrong = token.clone().into_bytes(); + wrong[MIN_TOKEN_CHARS - 1] = b'b'; + assert!(!authenticator.matches(&wrong)); + } + + #[test] + fn surrounding_whitespace_in_the_file_is_ignored() { + let temp = private_dir(); + let path = temp.path().join("token"); + let token = "b".repeat(MIN_TOKEN_CHARS); + let mut file = create_private(&path).expect("create"); + file.write_all(format!(" {token}\n").as_bytes()) + .expect("write"); + drop(file); + let authenticator = Authenticator::from_token_file(&path).expect("load"); + assert!(authenticator.matches(token.as_bytes())); + } + + #[test] + fn the_secret_never_appears_in_debug_output() { + let token = "c".repeat(MIN_TOKEN_CHARS); + let rendered = format!("{:?}", Authenticator::from_token(&token).expect("token")); + assert!(!rendered.contains(&token), "{rendered}"); + assert!(rendered.contains("redacted"), "{rendered}"); + } +} diff --git a/crates/andromeda-taskd/src/lib.rs b/crates/andromeda-taskd/src/lib.rs index ec2474d..6b40c64 100644 --- a/crates/andromeda-taskd/src/lib.rs +++ b/crates/andromeda-taskd/src/lib.rs @@ -1,5 +1,7 @@ //! Local HTTP API for the Andromeda task control plane. +pub mod auth; + use std::net::SocketAddr; use std::str::FromStr; use std::sync::Arc; @@ -17,12 +19,28 @@ use axum::routing::{get, post}; use axum::{Json, Router}; use serde_json::{Value, json}; +pub use auth::{AuthError, Authenticator}; + #[derive(Debug, Clone)] struct AppState { service: Arc, } -pub fn app(service: TaskService) -> Router { +/// Builds the authenticated API router. +/// +/// `authenticator` is a required, by-value argument, and [`Authenticator`] has +/// no variant meaning "no authentication" — so every router this function can +/// produce checks a bearer token on every route, including `/healthz`. That is +/// the whole enforcement story: there is no configuration flag, no environment +/// variable, and no alternative constructor that yields an anonymous listener, +/// which is what the previous design was rejected for +/// (`docs/reviews/remediation-design-review.md` §1, "认证保证要在 serve 接线上 +/// 不可绕过"). +/// +/// Layer order is deliberate: authentication is the *outermost* layer, so an +/// unauthenticated request is rejected before the Host check, before body +/// parsing, and before any store lock is taken. +pub fn app(service: TaskService, authenticator: Authenticator) -> Router { Router::new() .route("/healthz", get(health)) .route("/v1/tasks", get(list_tasks).post(create_task)) @@ -32,11 +50,50 @@ pub fn app(service: TaskService) -> Router { .route("/v1/tasks/{task_id}/evaluate", post(evaluate_task)) .route("/v1/tasks/{task_id}/transition", post(transition_task)) .layer(middleware::from_fn(require_loopback_host)) + .layer(middleware::from_fn_with_state( + Arc::new(authenticator), + require_token, + )) .with_state(AppState { service: Arc::new(service), }) } +/// Rejects any request that does not carry the local API token. +/// +/// The token is read from `Authorization: Bearer ` and compared in +/// constant time. A browser cannot attach this header to a cross-origin request +/// without a preflight the daemon never answers, so this also closes the +/// residual DNS-rebinding surface that the Host check alone left open. +async fn require_token( + State(authenticator): State>, + request: Request, + next: Next, +) -> Response { + let presented = request + .headers() + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .map(str::trim); + if presented.is_some_and(|token| authenticator.matches(token.as_bytes())) { + return next.run(request).await; + } + // The response says nothing about *why* — whether the header was absent, + // malformed, or simply wrong is not information a caller needs, and + // distinguishing them helps an attacker probe. + ( + StatusCode::UNAUTHORIZED, + [(header::WWW_AUTHENTICATE, "Bearer")], + Json(json!({ + "error": "unauthorized", + "message": "this API requires the local token: \ + Authorization: Bearer $(cat $ANDROMEDA_AUTH_TOKEN_FILE)", + })), + ) + .into_response() +} + /// Rejects requests that are not addressed to a loopback host. /// /// `taskd` binds to loopback by default, but a malicious web page can reach @@ -67,8 +124,8 @@ async fn require_loopback_host(request: Request, next: Next) -> Response { } } -/// A bind address was rejected because it would expose the unauthenticated -/// API beyond the local host. +/// A bind address was rejected because it would expose the API beyond the +/// local host. #[derive(Debug, PartialEq, Eq)] pub struct NonLoopbackBind { pub address: SocketAddr, @@ -78,9 +135,10 @@ impl std::fmt::Display for NonLoopbackBind { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( formatter, - "refusing to bind {}: taskd has no authentication, so binding beyond loopback would \ - expose the full task API to that network. The Host-header check only defends \ - browsers against DNS rebinding and does not protect a non-loopback bind. Set \ + "refusing to bind {}: taskd's only credential is a local bearer token designed for \ + same-host callers, so binding beyond loopback would put the full task API on that \ + network behind a single shared secret. The Host-header check only defends browsers \ + against DNS rebinding and does not protect a non-loopback bind. Set \ ANDROMEDA_ALLOW_NON_LOOPBACK=1 only inside an already-isolated network namespace.", self.address ) @@ -165,11 +223,19 @@ where .map_err(ApiError::from) } -async fn health() -> Json { +/// Liveness plus the security posture the daemon is actually running under. +/// +/// `capability_admission` is reported so an operator can verify from outside +/// the process whether issuer signatures are being enforced, instead of +/// inferring it from documentation. Reaching this route still requires the +/// token: a caller who cannot authenticate learns nothing at all. +async fn health(State(state): State) -> Json { Json(json!({ "status": "ok", "service": "andromeda-taskd", - "api_version": "v1" + "api_version": "v1", + "authentication": "bearer_token", + "capability_admission": state.service.admission().mode_name(), })) } @@ -299,6 +365,13 @@ impl IntoResponse for ApiError { | TransitionGuardError::UnsuccessfulOutcomes { .. } | TransitionGuardError::MissingEvidence { .. }, )) => (StatusCode::UNPROCESSABLE_ENTITY, "missing_evidence"), + // A refused capability asks for a different operator action from a + // malformed plan: obtain a grant the configured issuer signed, or + // reconfigure the keyring. It gets its own wire code so a client + // does not have to parse prose to tell the two apart. + Self::Service(ServiceError::Admission(_)) => { + (StatusCode::UNPROCESSABLE_ENTITY, "capability_not_admitted") + } Self::Service( ServiceError::Validation(_) | ServiceError::Transition(_) | ServiceError::Guard(_), ) => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_task"), @@ -330,7 +403,7 @@ mod tests { FileAccess, Intent, IsolationLevel, RecoverySemantics, RiskLevel, TaskState, }; use andromeda_policy::PolicyEngine; - use andromeda_runtime::FileTaskStore; + use andromeda_runtime::{CapabilityAdmission, FileTaskStore}; use axum::body::Body; use axum::http::Request; use chrono::Utc; @@ -342,12 +415,27 @@ mod tests { const LOCAL_HOST_HEADER: &str = "127.0.0.1:7777"; + /// The token every test authenticates with. A fixed literal, never a + /// generated one: tests must not depend on an RNG. + const TEST_TOKEN: &str = "0123456789abcdef0123456789abcdef"; + fn test_app(temp: &TempDir) -> Router { + test_app_with(temp, CapabilityAdmission::unsigned_for_development()) + } + + fn test_app_with(temp: &TempDir, admission: CapabilityAdmission) -> Router { let service = TaskService::new( FileTaskStore::open(temp.path()).expect("store"), PolicyEngine::default(), + admission, ); - app(service) + // Note what cannot be written here: there is no `app(service)`. Every + // router in every test, like every router in production, carries an + // Authenticator because the signature demands one. + app( + service, + Authenticator::from_token(TEST_TOKEN).expect("token"), + ) } fn inspection_request(path: &str) -> CreateTaskRequest { @@ -362,6 +450,7 @@ mod tests { issued_at: Utc::now(), expires_at: None, single_use: false, + signature: None, }; let plan = ActionPlan { schema_version: ActionPlan::CURRENT_SCHEMA_VERSION, @@ -391,10 +480,22 @@ mod tests { uri: &str, body: Option<&impl serde::Serialize>, ) -> Request { - let builder = Request::builder() + authenticated_request(method, uri, body, Some(TEST_TOKEN)) + } + + fn authenticated_request( + method: &str, + uri: &str, + body: Option<&impl serde::Serialize>, + token: Option<&str>, + ) -> Request { + let mut builder = Request::builder() .method(method) .uri(uri) .header(header::HOST, LOCAL_HOST_HEADER); + if let Some(token) = token { + builder = builder.header(header::AUTHORIZATION, format!("Bearer {token}")); + } match body { Some(body) => builder .header(header::CONTENT_TYPE, "application/json") @@ -756,6 +857,7 @@ mod tests { issued_at: Utc::now(), expires_at: None, single_use: false, + signature: None, }; let plan = ActionPlan { schema_version: ActionPlan::CURRENT_SCHEMA_VERSION, @@ -836,9 +938,13 @@ mod tests { async fn foreign_host_header_is_forbidden() { let temp = TempDir::new().expect("tempdir"); let app = test_app(&temp); + // Authenticated on purpose: this asserts the Host check still fires for + // a caller who *does* hold the token, so the two layers are independent + // rather than one masking the other. let request = Request::builder() .uri("/healthz") .header(header::HOST, "rebind.attacker.example:7777") + .header(header::AUTHORIZATION, format!("Bearer {TEST_TOKEN}")) .body(Body::empty()) .expect("request"); let (status, error) = send(&app, request).await; @@ -852,6 +958,7 @@ mod tests { let app = test_app(&temp); let request = Request::builder() .uri("/healthz") + .header(header::AUTHORIZATION, format!("Bearer {TEST_TOKEN}")) .body(Body::empty()) .expect("request"); let (status, error) = send(&app, request).await; @@ -976,6 +1083,7 @@ mod tests { .method("POST") .uri("/v1/tasks") .header(header::HOST, LOCAL_HOST_HEADER) + .header(header::AUTHORIZATION, format!("Bearer {TEST_TOKEN}")) .header(header::CONTENT_TYPE, "application/json") .body(Body::from(serde_json::to_vec(&value).expect("body"))) .expect("request"); @@ -983,6 +1091,242 @@ mod tests { assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); } + /// Every route, including `/healthz`, must refuse an unauthenticated + /// caller. This is the runtime half of the guarantee; the compile-time half + /// is that `app` cannot be called without an [`Authenticator`] at all. + #[tokio::test] + async fn every_route_rejects_a_caller_without_the_token() { + let temp = TempDir::new().expect("tempdir"); + let app = test_app(&temp); + let task_id = TaskId::new(); + let routes = [ + ("GET", "/healthz".to_owned()), + ("GET", "/v1/tasks".to_owned()), + ("POST", "/v1/tasks".to_owned()), + ("GET", format!("/v1/tasks/{task_id}")), + ("POST", format!("/v1/tasks/{task_id}/capabilities")), + ("POST", format!("/v1/tasks/{task_id}/outcomes")), + ("POST", format!("/v1/tasks/{task_id}/evaluate")), + ("POST", format!("/v1/tasks/{task_id}/transition")), + ]; + for (method, uri) in routes { + let request = authenticated_request(method, &uri, None::<&serde_json::Value>, None); + let (status, error) = send(&app, request).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "{method} {uri}"); + assert_eq!(error["error"], "unauthorized", "{method} {uri}"); + } + } + + /// Wrong, truncated, extended, and wrongly-framed tokens must all fail. + #[tokio::test] + async fn only_the_exact_token_is_accepted() { + let temp = TempDir::new().expect("tempdir"); + let app = test_app(&temp); + for wrong in [ + "", + "wrong", + &TEST_TOKEN[..TEST_TOKEN.len() - 1], + &format!("{TEST_TOKEN}x"), + &TEST_TOKEN.to_uppercase(), + ] { + let request = + authenticated_request("GET", "/healthz", None::<&serde_json::Value>, Some(wrong)); + let (status, _) = send(&app, request).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "token {wrong:?}"); + } + + // A correct token in the wrong scheme is still not authentication. + let request = Request::builder() + .uri("/healthz") + .header(header::HOST, LOCAL_HOST_HEADER) + .header(header::AUTHORIZATION, format!("Basic {TEST_TOKEN}")) + .body(Body::empty()) + .expect("request"); + let (status, _) = send(&app, request).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + + let request = authenticated_request( + "GET", + "/healthz", + None::<&serde_json::Value>, + Some(TEST_TOKEN), + ); + let (status, body) = send(&app, request).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["authentication"], "bearer_token"); + } + + /// An unauthenticated listener is not constructible. The compile-time part + /// cannot be asserted at runtime, so this test pins the two properties that + /// make it true and that a future refactor could quietly remove: no + /// `Authenticator` can be built from an empty or trivially short secret, + /// and the only way to reach a `Router` is through `app`, which demands one. + #[test] + fn an_unauthenticated_listener_cannot_be_constructed() { + // Were an `Authenticator::none()`-style escape hatch ever added, it + // would have to produce a value accepting the empty credential. No such + // value exists: every constructor is fallible and rejects weak input. + assert!(Authenticator::from_token("").is_err()); + assert!(Authenticator::from_token(" ").is_err()); + assert!(Authenticator::from_token(&"a".repeat(auth::MIN_TOKEN_CHARS - 1)).is_err()); + + let authenticator = Authenticator::from_token(TEST_TOKEN).expect("token"); + assert!(!authenticator.matches(b"")); + assert!(authenticator.matches(TEST_TOKEN.as_bytes())); + + // `app` takes the authenticator by value; there is no second + // constructor, and `Router` is only reachable through it. + let temp = TempDir::new().expect("tempdir"); + let _: Router = test_app(&temp); + } + + /// Reports the capability admission mode so an operator can see, from + /// outside the process, whether issuer signatures are enforced. + #[tokio::test] + async fn health_reports_the_capability_admission_mode() { + let temp = TempDir::new().expect("tempdir"); + let app = test_app(&temp); + let (status, body) = send(&app, local_request("GET", "/healthz", None::<&Value>)).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["capability_admission"], "unsigned_allowed"); + + let signing_key = andromeda_core::CapabilitySigningKey::from_seed(&[3u8; 32]); + let mut keyring = andromeda_core::CapabilityKeyring::new(); + keyring + .insert_hex("issuer-2026", &signing_key.verifying_key_hex()) + .expect("key"); + let signed_temp = TempDir::new().expect("tempdir"); + let signed_app = test_app_with( + &signed_temp, + CapabilityAdmission::require_signed(keyring).expect("keyring"), + ); + let (status, body) = send( + &signed_app, + local_request("GET", "/healthz", None::<&Value>), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["capability_admission"], "require_signed"); + } + + /// End-to-end over HTTP: with a keyring configured, an unsigned capability + /// is refused at `POST /v1/tasks`, and the same capability signed by the + /// trusted issuer is accepted. + #[tokio::test] + async fn create_enforces_capability_signatures_when_a_keyring_is_configured() { + let signing_key = andromeda_core::CapabilitySigningKey::from_seed(&[4u8; 32]); + let mut keyring = andromeda_core::CapabilityKeyring::new(); + keyring + .insert_hex("issuer-2026", &signing_key.verifying_key_hex()) + .expect("key"); + let temp = TempDir::new().expect("tempdir"); + let app = test_app_with( + &temp, + CapabilityAdmission::require_signed(keyring).expect("keyring"), + ); + + let mut request = inspection_request(workspace_path()); + let (status, error) = send(&app, local_request("POST", "/v1/tasks", Some(&request))).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(error["error"], "capability_not_admitted"); + assert!( + error["message"] + .as_str() + .expect("message") + .contains("no issuer signature"), + "{error}" + ); + + signing_key + .sign_in_place(&mut request.capabilities[0], "issuer-2026") + .expect("sign"); + let (status, _) = send(&app, local_request("POST", "/v1/tasks", Some(&request))).await; + assert_eq!(status, StatusCode::CREATED); + } + + /// The rework constraint that the key directory's protection and the + /// service's runtime identity are defined in **one** place, with an + /// assertion. The constants in `crate::auth` are that place; this asserts + /// the shipped unit agrees with them, so the pair cannot drift into the + /// "0700 root:root directory under `DynamicUser`" failure that sank the + /// previous attempt. + #[test] + fn the_shipped_unit_matches_the_token_permission_constants() { + let unit_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../os/files/usr/lib/systemd/system/andromeda-taskd.service"); + let unit = std::fs::read_to_string(&unit_path) + .unwrap_or_else(|error| panic!("read {}: {error}", unit_path.display())); + let directives: Vec<&str> = unit + .lines() + .map(str::trim) + .filter(|line| !line.starts_with('#') && !line.is_empty()) + .collect(); + let has = |directive: &str| directives.iter().any(|line| *line == directive); + + // The directory holding the token, and its mode, come from the code. + assert!( + has(&format!( + "RuntimeDirectory={}", + auth::RUNTIME_DIRECTORY_NAME + )), + "unit must create the token directory systemd-side: {directives:?}" + ); + assert!( + has(&format!( + "RuntimeDirectoryMode={:04o}", + auth::TOKEN_DIR_MODE + )), + "RuntimeDirectoryMode must equal auth::TOKEN_DIR_MODE" + ); + assert!( + has(&format!("StateDirectoryMode={:04o}", auth::TOKEN_DIR_MODE)), + "StateDirectoryMode must equal auth::TOKEN_DIR_MODE" + ); + assert!( + has(&format!( + "Environment=ANDROMEDA_AUTH_TOKEN_FILE={}", + auth::SYSTEM_TOKEN_PATH + )), + "the unit must point taskd at the documented token path" + ); + + // Identity: exactly one runtime identity is declared, and it is the + // dynamic one. A static `User=`/`Group=` here would reintroduce the + // two-places problem, because nothing in the tree would own that name. + assert!(has("DynamicUser=yes"), "{directives:?}"); + assert!( + !directives + .iter() + .any(|line| line.starts_with("User=") || line.starts_with("Group=")), + "the unit must not name a static identity: {directives:?}" + ); + + // UMask must not be looser than the file mode the code creates, or the + // unit would silently contradict `auth::TOKEN_FILE_MODE`. + let umask = directives + .iter() + .find_map(|line| line.strip_prefix("UMask=")) + .expect("unit must set UMask"); + let umask = u32::from_str_radix(umask, 8).expect("octal UMask"); + assert_eq!( + !umask & 0o777 & auth::PRIVATE_MODE_MASK, + 0, + "UMask={umask:04o} would permit group/other access the code forbids" + ); + assert_eq!( + auth::TOKEN_FILE_MODE & auth::PRIVATE_MODE_MASK, + 0, + "TOKEN_FILE_MODE must grant nothing beyond the owner" + ); + + // Nothing may re-enable anonymous access. There is no such switch, and + // this asserts none is introduced by way of the unit. + assert!( + !unit.contains("ANDROMEDA_ALLOW_NON_LOOPBACK"), + "the shipped unit must not opt out of the loopback bind check" + ); + } + #[cfg(not(target_os = "windows"))] const fn workspace_path() -> &'static str { "/workspace" diff --git a/crates/andromeda-taskd/src/main.rs b/crates/andromeda-taskd/src/main.rs index 536316a..0daed8c 100644 --- a/crates/andromeda-taskd/src/main.rs +++ b/crates/andromeda-taskd/src/main.rs @@ -1,10 +1,13 @@ +use std::collections::BTreeMap; use std::net::SocketAddr; use std::path::PathBuf; +use andromeda_core::CapabilityKeyring; use andromeda_policy::PolicyEngine; -use andromeda_runtime::{FileTaskStore, TaskService}; +use andromeda_runtime::{CapabilityAdmission, FileTaskStore, TaskService}; +use andromeda_taskd::Authenticator; use clap::Parser; -use tracing::info; +use tracing::{info, warn}; use tracing_subscriber::EnvFilter; #[derive(Debug, Parser)] @@ -14,6 +17,32 @@ struct Args { listen: SocketAddr, #[arg(long, env = "ANDROMEDA_STATE_DIR", default_value = ".andromeda/state")] state_dir: PathBuf, + /// File holding the local API bearer token. + /// + /// Generated on first start if absent, with mode 0600 in a directory that + /// must already grant nothing to group or other. There is no flag to serve + /// without it: the API router cannot be built without an + /// [`Authenticator`], so a missing or unusable token file is a startup + /// failure, never a downgrade to anonymous access. + /// + /// The shipped unit points this at `/run/andromeda-taskd/token`, inside + /// systemd's `RuntimeDirectory=`. + #[arg( + long, + env = "ANDROMEDA_AUTH_TOKEN_FILE", + default_value = ".andromeda/taskd-token" + )] + auth_token_file: PathBuf, + /// JSON file mapping capability issuer key ids to ed25519 verifying keys + /// in hex: `{"issuer-2026": "<64 hex chars>"}`. + /// + /// When supplied, every capability offered at task creation or grant must + /// carry a signature that verifies against one of these keys. When absent, + /// unsigned capabilities are accepted — the v0 posture, which the daemon + /// warns about at startup and reports on `/healthz`, because no component + /// in this repository issues capabilities yet. + #[arg(long, env = "ANDROMEDA_CAPABILITY_KEYRING")] + capability_keyring: Option, /// Permit binding to a non-loopback address. The API has no /// authentication, so this exposes every task, plan, and capability to /// that network; only meaningful inside an already-isolated network @@ -32,32 +61,108 @@ struct Args { allow_non_loopback: bool, } +/// Runs the daemon, reporting a startup failure as a *readable* message. +/// +/// `fn main() -> Result<_, _>` would print the error's `Debug`, not its +/// `Display` — so a permission refusal surfaced as +/// `TooPermissive { path: "...", mode: 488 }`: the octal mode rendered in +/// decimal, and none of the guidance the error type carefully spells out. Every +/// startup error here exists to tell an operator what to change, so `main` +/// prints `Display` and walks the `source()` chain instead. #[tokio::main] -async fn main() -> Result<(), Box> { +async fn main() -> std::process::ExitCode { tracing_subscriber::fmt() .with_env_filter( EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), ) .init(); + match run().await { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(error) => { + tracing::error!("{error}"); + let mut source = error.source(); + while let Some(cause) = source { + tracing::error!(" caused by: {cause}"); + source = cause.source(); + } + std::process::ExitCode::FAILURE + } + } +} + +async fn run() -> Result<(), Box> { let args = Args::parse(); andromeda_taskd::ensure_loopback_bind(args.listen, args.allow_non_loopback)?; if !args.listen.ip().to_canonical().is_loopback() { - tracing::warn!( + warn!( listen = %args.listen, "binding beyond loopback with ANDROMEDA_ALLOW_NON_LOOPBACK; the task API is \ - UNAUTHENTICATED and now reachable from this network" + reachable from this network and is protected only by the local bearer token" ); } + // Built before the store and before the listener: if the token cannot be + // established, the daemon must not reach a state where it could serve. + let authenticator = Authenticator::from_token_file(&args.auth_token_file)?; + let admission = load_admission(args.capability_keyring.as_deref())?; let store = FileTaskStore::open(&args.state_dir)?; - let service = TaskService::new(store, PolicyEngine::default()); + let service = TaskService::new(store, PolicyEngine::default(), admission); let listener = tokio::net::TcpListener::bind(args.listen).await?; - info!(listen = %args.listen, state_dir = %args.state_dir.display(), "task service ready"); - axum::serve(listener, andromeda_taskd::app(service)) + info!( + listen = %args.listen, + state_dir = %args.state_dir.display(), + auth_token_file = %args.auth_token_file.display(), + capability_admission = service.admission().mode_name(), + "task service ready; every request requires Authorization: Bearer " + ); + axum::serve(listener, andromeda_taskd::app(service, authenticator)) .with_graceful_shutdown(shutdown_signal()) .await?; Ok(()) } +/// Resolves the capability admission policy from an optional keyring file. +/// +/// Absent file means the v0 posture: capabilities are accepted unsigned. That +/// is warned about loudly rather than silently defaulted, because it is the +/// posture in which a caller still mints its own grants (security review #3). +fn load_admission( + keyring_path: Option<&std::path::Path>, +) -> Result> { + let Some(path) = keyring_path else { + warn!( + "no capability keyring configured: capabilities are accepted UNSIGNED, so a caller \ + still issues its own grants. Pass --capability-keyring once a trusted issuer exists." + ); + return Ok(CapabilityAdmission::unsigned_for_development()); + }; + let contents = std::fs::read_to_string(path).map_err(|error| { + format!( + "could not read capability keyring {}: {error}", + path.display() + ) + })?; + let entries: BTreeMap = serde_json::from_str(&contents).map_err(|error| { + format!( + "capability keyring {} must be a JSON object of {{\"key_id\": \"<64 hex chars>\"}}: \ + {error}", + path.display() + ) + })?; + let keyring = CapabilityKeyring::from_hex_entries(entries)?; + let key_ids: Vec = keyring.key_ids().map(str::to_owned).collect(); + // `require_signed` rejects an empty keyring, so a typo that parses to `{}` + // is a startup failure rather than a daemon that refuses every request + // while looking hardened. + let admission = CapabilityAdmission::require_signed(keyring)?; + info!( + keyring = %path.display(), + trusted_key_ids = ?key_ids, + "capability signatures are REQUIRED; unsigned or untrusted grants are refused" + ); + Ok(admission) +} + /// Completes when a shutdown signal (Ctrl+C, or SIGTERM on Unix) arrives. /// /// A failure to install a signal handler must not shut the daemon down, so diff --git a/docs/andromeda-threat-model.md b/docs/andromeda-threat-model.md index 8083985..ca213dd 100644 --- a/docs/andromeda-threat-model.md +++ b/docs/andromeda-threat-model.md @@ -124,18 +124,46 @@ quarantine/下载/外部挂载)、文件类型嗅探与污点标记,独立 - `issued_to` 必须等于 `plan.task_id`; - 单任务 capability 总量上限 `MAX_TASK_CAPABILITIES = 10_000`:创建与补授两条路径都按 **授予后的总量**(而非单次请求的数量)强制,重复条目按条计数,反复补授无法越过上限。 - -**已知弱点(未修,最严重)**:**capability 由调用方自带且无签名**。`CreateTaskRequest` -同时携带 plan 与 capabilities,而 `task_id` 也由调用方自选,因此 -`issued_to == task_id` 不构成任何约束——T3 可以自签发 `Files{root:"/", ReadWrite}`。 -`taskd` 本身**无鉴权**,本地任意进程经 loopback 即可驱动全部 API。 + 该上界同时是**签名校验的前置门**:`create` 先跑 `validate_plan`(内含上界), + `grant_capabilities` 先算授予后总量,之后才做 Ed25519 验签;`CapabilityAdmission::admit` + 自身再复述一次该上界(`AdmissionError::Unbounded`),因此对**无界**输入强制验签 + 这条本地 DoS 路径不可达。 +- **本地主体认证(已落地)**:`taskd` 的每个请求都必须携带本地 bearer 令牌, + `/healthz` 也不例外。保证点在 **serve 接线**上:`andromeda_taskd::app` 必须接收 + `Authenticator`,而该类型没有"匿名/关闭"变体、所有构造函数均可失败且拒绝空或过短的 + 秘密——**匿名监听在类型上不可表示**,不存在可绕过的开关、环境变量或配置项。 + 令牌目录/文件的权限模型只在 `crates/andromeda-taskd/src/auth.rs` 定义一处, + 启动时断言,并由单元测试断言镜像内 `andromeda-taskd.service` 与之一致。 +- **capability 签名机制(已落地,默认未启用)**:`Capability` 可携带 detached Ed25519 + 签名,`CapabilityAdmission::RequireSigned(keyring)` 下未签名/未知密钥/格式错误/ + 签名后被篡改的 capability 一律拒绝(创建与补授两条路径同等强制)。 + +**已知弱点(未修,最严重)**:**capability 仍由调用方自带,且镜像默认不要求签名**。 +`CreateTaskRequest` 同时携带 plan 与 capabilities,而 `task_id` 也由调用方自选,因此 +`issued_to == task_id` 不构成任何约束——在默认的 `unsigned_allowed` 模式下, +通过认证的调用方仍可自签发 `Files{root:"/", ReadWrite}`。 + +签名机制存在**不等于**该弱点已修:**持私钥者即签发方**,而当前仓库没有任何 +capability 签发组件,因此镜像里的 `andromeda-taskd.service` 刻意不配置 keyring +(配了就会拒绝所有现存客户端的全部请求)。运行中的模式可由 `/healthz` 的 +`capability_admission` 字段观测,不必从文档推断。 + +本地认证把攻击面从"**本机任意进程/用户**"收敛到"**服务账号与 root**":令牌位于 +`RuntimeDirectory`(`0700`)下的 `0600` 文件,其他本地用户读不到。它**不**防御 +已取得 root 或服务账号的攻击者,也**不**提供远程认证或用户身份。 **落地前必须成立**(executor 的阻塞前置项): -1. capability 由**受信签发方**产出并带签名,`taskd` 拒绝无签名的裸能力; -2. `taskd` 具备**本地主体认证**,请求方身份不再自报; +1. ~~capability 由受信签发方产出并带签名,`taskd` 拒绝无签名的裸能力~~ + → **机制已落地,签发方仍缺**。验签、keyring、fail-closed 拒绝均已实现且有对抗测试; + 仍阻塞的是**受信签发组件**:必须由调用方够不到的宿主组件持有私钥并签发, + 届时镜像才能把 `ANDROMEDA_CAPABILITY_KEYRING` 打开、把默认模式改为 `require_signed`。 +2. ~~`taskd` 具备本地主体认证,请求方身份不再自报~~ → **已落地**(见上)。 + 残留:认证只证明"调用方持有本机令牌",**尚未把认证结果作为主体传入策略评估**(见第 3 条)。 3. `EvaluationContext::subject` 已接入 `evaluate` 路径,但其值由请求方自报, - 且安全相关的 `Ready → Running` 守卫并不应用它;它必须改为绑定认证后的主体, - 并在该守卫上生效。 + 且安全相关的 `Ready → Running` 守卫并不应用它;它必须改为绑定**认证后的**主体, + 并在该守卫上生效。**仍未做**:本轮的令牌是单一共享秘密,不携带可区分的主体身份, + 因此在引入多主体身份(或 `SO_PEERCRED`/`AF_UNIX` 之类可区分调用方的传输)之前, + 把它接进评估上下文只会产生一个恒定值,不构成约束。 ### 4.3 边界 B3:授权 → 执行(隔离与确认) @@ -249,14 +277,21 @@ sigstoreSigned 模板已存在(`os/signing/policy.json.example`),但未安 | 攻击 | 当前是否可行 | 依据 | |---|---|---| -| 无鉴权驱动全部任务 API | **可行** | `taskd` 无任何鉴权 | -| 自签发任意 capability | **可行** | 见 §4.2 | -| 读取他人任务的 plan/capability/事件 | 视权限而定 | state 目录由 `create_dir_all` 创建,无显式 mode;生产环境靠 systemd `UMask=0077` 与 `DynamicUser` 兜底 | -| 通过反复 `evaluate` 撑爆磁盘 | **可行** | 每次 evaluate 追加含全部决策的事件并 fsync 整条记录 | +| 无鉴权驱动全部任务 API | **已阻断** | 每个请求(含 `/healthz`)须带本地 bearer 令牌;令牌在 `0700` 目录下的 `0600` 文件中,仅服务账号与 root 可读 | +| 以服务账号或 root 身份驱动全部 API | **可行(设计如此)** | 令牌是文件权限的投影,不防御已具备该身份的攻击者 | +| 自签发任意 capability | **可行** | 镜像默认 `unsigned_allowed`;见 §4.2 | +| 读取他人任务的 plan/capability/事件 | 视权限而定 | state 目录由 `create_dir_all` 创建,无显式 mode;生产环境靠 systemd `UMask=0077`、`StateDirectoryMode=0700` 与 `DynamicUser` 兜底 | +| 通过反复 `evaluate` 撑爆磁盘 | **可行(需先通过认证)** | 每次 evaluate 追加含全部决策的事件并 fsync 整条记录 | +| 对无界 capability 向量强制验签(本地 DoS) | **已阻断** | 长度上界先于任何密码学校验,见 §4.2 | | 把 API 暴露到网络 | **已阻断** | 启动时校验监听地址,非回环拒绝启动,需显式 opt-out | -**落地前必须成立**:本地主体认证(`SO_PEERCRED`/UNIX socket 或等价物)+ capability -签发分离 + 对 `evaluate` 之类无副作用但可放大写入的端点加配额。 +**落地前必须成立**:~~本地主体认证~~(已落地:bearer 令牌,保证点在 serve 接线) ++ capability **签发方**分离(机制已落地,签发组件仍缺) ++ 对 `evaluate` 之类无副作用但可放大写入的端点加配额(**仍未做**)。 + +注意本轮令牌**不是**主体认证的完全形态:它是单一共享秘密,能回答"调用方是不是本机 +服务账号/root",不能区分多个调用方。要把认证结果作为 `subject` 送进策略评估 +(§4.2 第 3 条),需要能区分调用方的传输(`SO_PEERCRED`/`AF_UNIX`)或真实的用户身份。 ### 5.4 A8 确认疲劳(人因威胁) @@ -297,9 +332,18 @@ fail-closed 到 `Blocked`;`--require-tier supported|certified` 不带 stub),**不得当作真实性来源**——制品真实性目前依赖"已验签清单认证其 `sha256` + `--artifact-root` 重算比对"这条链。 -### 6.2 capability 自签发 + taskd 无鉴权 +### 6.2 capability 自签发〔taskd 无鉴权已修复;残余:没有签发方〕 + +见 §4.2、§5.3。**taskd 无鉴权已修复**:本地 bearer 令牌,且"匿名监听"在类型上不可表示, +镜像内单元的行为与本文一致(有单元测试断言二者不漂移)。 -见 §4.2、§5.3。**executor 落地前的阻塞项**。 +**仍是 executor 落地前的阻塞项**的是 capability 自签发。验签机制、keyring 与 +fail-closed 拒绝都已实现,但**没有任何组件签发 capability**,因此镜像默认运行在 +`unsigned_allowed`:通过认证的调用方依旧可以自铸任意 capability。 +把"机制存在"当成"弱点已修"是本条最容易犯的错误——持私钥者即签发方, +在私钥归属受信宿主组件之前,签名只证明"某人签过",不证明"调用方无权自签"。 + +同时留意残余的密钥管理问题与 §6.1 相同:签发密钥的生成、分发、轮换与撤销均未建立。 ### 6.3 deny root 是纯词法且对 symlink 盲 @@ -360,12 +404,21 @@ stub),**不得当作真实性来源**——制品真实性目前依赖"已 | 不可信网页文本升级为系统指令 | S-2、§5.1 | 否(无执行器) | | 插件越过声明权限 | §5.2 | 否(无工具注册表) | | 高影响动作绕过提交确认 | §4.3 | **是**:L3 确认门有回归测试 | -| 审计记录缺失主体或工具 | §6.6 | 部分:主体未认证 | +| 审计记录缺失主体或工具 | §6.6 | 部分:调用方已认证(本地令牌),但 `actor` 仍是请求体自由文本,二者尚未绑定 | | 撤销声称成功但状态未恢复 | §4.4 | 否(无 rollback executor) | | 云端路由违反本地数据策略 | T9 | 否(无模型运行时) | -**结论**:8 条发布阻断条件中,今天只有 1 条具备机器可验证的强制点。这准确反映了 -项目所处阶段——控制面契约已成型,执行面尚未开始。 +**结论**:8 条发布阻断条件中,今天只有 1 条具备机器可验证的强制点;"审计记录缺失主体" +一条从"主体完全未认证"前进到"传输层已认证、但认证结果尚未绑定审计 `actor`"。 +这准确反映了项目所处阶段——控制面契约已成型,执行面尚未开始。 + +不属于上表、但本轮取得的进展与其边界: + +| 项 | 状态 | 边界 | +|---|---|---| +| taskd 本地鉴权 | **已落地** | 保证点在 serve 接线(`app` 必须收 `Authenticator`,无匿名变体);只区分"服务账号/root"与"其他本地用户",无远程认证、无用户身份 | +| capability 签名 | **机制已落地,镜像未启用** | 无签发组件,故默认 `unsigned_allowed`;持私钥者即签发方,机制本身**不足以**关闭 §6.2 | +| 强制验签的输入上界 | **已落地** | 长度上界先于 Ed25519,`AdmissionError::Unbounded` 复述该前置条件 | ## 9. 复审触发条件 @@ -379,6 +432,14 @@ stub),**不得当作真实性来源**——制品真实性目前依赖"已 - 安装器获得分区修改能力(缩盘/双系统); - 任何一条 §6 的攻击面被修复或被扩大。 +**已触发并已完成的复审**: + +- 2026-08-02,"capability 签发方式变化" + "§6.2 攻击面被部分修复"—— + capability 引入可选 detached Ed25519 签名与 `CapabilityAdmission`, + `taskd` 引入强制本地 bearer 令牌鉴权。本次复审重写了 §4.2、§5.3、§6.2 与 §8, + 并明确记录:**本地鉴权已成立,capability 签发方仍缺**, + 因此 §6.2 只是部分修复,仍为 executor 的阻塞前置项。 + ## 10. 相关文档 - [可靠更新、隔离与 AI Agent](./research/reliability-update-ai-agent.md) §7 —— capability、 diff --git a/docs/development/getting-started.md b/docs/development/getting-started.md index d98914b..e9fea8c 100644 --- a/docs/development/getting-started.md +++ b/docs/development/getting-started.md @@ -72,11 +72,26 @@ cargo run --locked --bin andromeda -- \ RUST_LOG=info cargo run --locked --bin andromeda-taskd ``` -默认地址是 `127.0.0.1:7777`,状态目录是 `.andromeda/state`: +默认地址是 `127.0.0.1:7777`,状态目录是 `.andromeda/state`,令牌文件是 +`.andromeda/taskd-token`(首次启动时自动生成,权限 `0600`)。 + +**每个请求都必须带本地令牌**,`/healthz` 也不例外: ```bash -curl http://127.0.0.1:7777/healthz -curl http://127.0.0.1:7777/v1/tasks +TOKEN=$(cat .andromeda/taskd-token) +curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:7777/healthz +curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:7777/v1/tasks ``` -当前 API 无远程认证,不得改为公网监听。 +不带令牌一律 401 `unauthorized`。令牌无法关闭:`andromeda_taskd::app` 必须传入 +`Authenticator`,而该类型没有"匿名"变体,因此**匿名监听在类型上不可表示**。 + +令牌所在目录必须对 group/other 完全不可访问(`0700`),否则 taskd 拒绝启动并说明原因。 + +`/healthz` 的 `capability_admission` 字段报告当前 capability 准入模式:默认 +`unsigned_allowed`(调用方仍可自铸 capability);传 `--capability-keyring` +(`ANDROMEDA_CAPABILITY_KEYRING`,JSON `{"key_id": "<64 位十六进制>"}`)后变为 +`require_signed`,未签名/未知密钥/被篡改的 capability 一律拒绝。 + +当前 API 仍无**远程**认证与用户身份,本地令牌只区分"本机同一账号/root"与"其他本地用户", +不得改为公网监听。 diff --git a/docs/development/task-control-plane.md b/docs/development/task-control-plane.md index de270eb..5f2381b 100644 --- a/docs/development/task-control-plane.md +++ b/docs/development/task-control-plane.md @@ -30,9 +30,11 @@ ActionKind 决定不可降低的风险下限。模型可以把动作声明得更 ## API +**所有**路径(含 `/healthz`)都要求 `Authorization: Bearer <令牌>`,否则 401 `unauthorized`。详见下方"本地鉴权"。 + | 方法 | 路径 | 作用 | |---|---|---| -| GET | `/healthz` | 服务状态与 API 版本 | +| GET | `/healthz` | 服务状态、API 版本,以及当前安全姿态:`authentication`(恒为 `bearer_token`)与 `capability_admission`(`unsigned_allowed` / `require_signed`) | | POST | `/v1/tasks` | 校验并创建任务(重复 task_id 返回 409 `already_exists`) | | GET | `/v1/tasks` | 列出任务,响应为 `{"tasks": [...], "warnings": [...]}`;损坏的记录文件被跳过并记入 `warnings`,不会让整个列表失败 | | GET | `/v1/tasks/{id}` | 读取任务 | @@ -43,15 +45,46 @@ ActionKind 决定不可降低的风险下限。模型可以把动作声明得更 所有 `TaskService` 调用在 `tokio::task::spawn_blocking` 中执行,阻塞的文件锁和 fsync 不会占用 async worker,`/healthz` 在锁竞争时依旧可响应。 +### 本地鉴权(强制,不可关闭) + +**每个请求都必须携带本地 bearer 令牌,`/healthz` 也不例外**: + +```http +Authorization: Bearer <令牌> +``` + +缺失、格式错误或不匹配一律返回 401 `unauthorized`,响应带 `WWW-Authenticate: Bearer`,且**不区分失败原因**(区分只会帮助攻击者试探)。令牌比较是常数时间的。 + +**保证点在 serve 接线,不在配置校验**:`andromeda_taskd::app(service, authenticator)` 必须接收一个 `Authenticator`;该类型没有 `Default`、没有公开字段、没有任何表示"匿名/关闭"的变体,且所有构造函数都可失败并拒绝空或短于 32 字符的秘密。因此**"匿名监听"在类型上不可表示**——不存在能关掉鉴权的命令行开关、环境变量或配置项,镜像里的 systemd 单元也没有这样的指令。鉴权中间件是**最外层**,未认证请求在 Host 校验、请求体解析和任何存储锁之前就被拒绝。 + +**令牌文件**:`--auth-token-file`(`ANDROMEDA_AUTH_TOKEN_FILE`,默认 `.andromeda/taskd-token`,镜像内为 `/run/andromeda-taskd/token`)。文件不存在时由 taskd 生成 32 字节 CSPRNG 随机值(十六进制 64 字符),以 `0600` 原子写入;已存在则复用(重启不会踢掉持有旧令牌的调用方)。 + +**权限与身份模型只定义一处**:`crates/andromeda-taskd/src/auth.rs` 的常量定义令牌目录(`0700`)与文件(`0600`)必须具备的权限,`ensure_private` 在**启动时断言**,不满足即拒绝启动并说明原因。单元 `andromeda-taskd.service` 的 `RuntimeDirectory=`/`RuntimeDirectoryMode=`/`StateDirectoryMode=`/`UMask=` 与这些常量的一致性由单元测试断言,二者不会漂移。仓库中**没有任何文件写死 uid/gid**:服务身份就是 systemd 分配的 `DynamicUser`,目录由 systemd 按该身份创建,taskd 从不自己 `mkdir`。 + +**这条边界能保证与不能保证的**:令牌位于只有属主可访问的目录中,因此它把调用方从"本机任意进程/用户"收敛为"**服务账号与 root**"。它**不**防御已取得 root 或服务账号的攻击者,**不**提供远程认证或用户身份,也**不**区分多个调用方(是单一共享秘密,因此还不能作为策略评估的 `subject`)。运维以 root 读取令牌驱动 API:`Authorization: Bearer $(sudo cat /run/andromeda-taskd/token)`。 + +### capability 准入(签名) + +`/healthz` 返回 `capability_admission`,报告当前模式: + +- `unsigned_allowed`(默认,也是镜像内的模式):接受未签名 capability。**这不是安全边界**——通过认证的调用方仍可自铸任意 capability,即安全评审 #3 记录的情形。 +- `require_signed`:由 `--capability-keyring`(`ANDROMEDA_CAPABILITY_KEYRING`)指定 JSON `{"key_id": "<64 位十六进制 ed25519 公钥>"}` 启用。此后创建与补授两条路径都要求每个 capability 携带能被 keyring 验证的 detached 签名;未签名、未知 key、格式错误、签名后被篡改一律以 422 `capability_not_admitted` 拒绝。空 keyring 直接启动失败(否则会伪装成已加固却拒绝一切)。 + +镜像**刻意不配置** keyring:仓库中尚无任何 capability 签发组件,配置了就会拒绝现有客户端的全部请求。签名机制本身**不**关闭"能力自签发"——持私钥者即签发方,见[威胁模型](../andromeda-threat-model.md) §4.2、§6.2。 + +`Capability.signature` 是可选字段,缺省时序列化输出与旧版本逐字节一致,旧的未签名记录照常解析为"未签名",因此升级不会作废已持久化的任务。 + +**强制验签的输入必须先有长度上界**:`create` 先跑 `validate_plan`(内含 `MAX_TASK_CAPABILITIES`),`grant_capabilities` 先算授予后总量(必然覆盖单次请求长度),之后才做 Ed25519 验证;`CapabilityAdmission::admit` 自身再复述一次该上界。因此不存在"对无界向量强制验签"的本地 DoS 路径。 + ### Host 校验(DNS rebinding 防护) `taskd` 校验每个请求的 `Host`(HTTP/2 下回退到 `:authority`):只接受 `localhost` 与字面回环 IP(127.0.0.0/8、`[::1]` 及其 IPv4-mapped 形式,可带端口),其余一律 403 `forbidden_host`。恶意网页即使通过 DNS rebinding 把自己的域名解析到 127.0.0.1,请求携带的仍是攻击者的 Host,会被拒绝。 -注意:Host 校验**只防御浏览器发起的 DNS rebinding**,不是鉴权,也**不能保护非 loopback 绑定**。它只检查请求携带的 `Host` 头取值,不检查实际入站接口。任何非浏览器客户端(curl/脚本/攻击者)都可以自带 `Host: localhost` 通过校验。此外,本地任意进程/用户经 loopback 亦可无鉴权访问 API。远程鉴权在下述能力实现前不存在(参见 `getting-started.md`、`README.md` 的一致说明)。 +注意:Host 校验**只防御浏览器发起的 DNS rebinding**,不是鉴权,也**不能保护非 loopback 绑定**。它只检查请求携带的 `Host` 头取值,不检查实际入站接口。任何非浏览器客户端(curl/脚本/攻击者)都可以自带 `Host: localhost` 通过校验——但仍需持有本地令牌(见上方"本地鉴权")。两层相互独立:持令牌的调用方依旧会被 Host 校验拦下,反之亦然。**远程**鉴权与用户身份在下述能力实现前不存在(参见 `getting-started.md`、`README.md` 的一致说明)。 ### 绑定地址强制(非 Host 校验) -因为 Host 校验保护不了绑定面,`taskd` 在**启动时**校验监听地址:非回环地址直接拒绝启动并说明原因。只有显式设置 `ANDROMEDA_ALLOW_NON_LOOPBACK=1`(或 `--allow-non-loopback`)才能越过,并会打印醒目警告说明 API 无鉴权。这把"禁止绑定 loopback 之外"从文档约定变成机制。 +因为 Host 校验保护不了绑定面,`taskd` 在**启动时**校验监听地址:非回环地址直接拒绝启动并说明原因。只有显式设置 `ANDROMEDA_ALLOW_NON_LOOPBACK=1`(或 `--allow-non-loopback`)才能越过,并会打印醒目警告:此时整个 API 会以一个为同机调用设计的共享令牌暴露在该网络上。这把"禁止绑定 loopback 之外"从文档约定变成机制。镜像内的单元不设置该变量(有单元测试断言这一点)。 生产部署另有内核级纵深防御:`andromeda-taskd.service` 设置 `IPAddressAllow=localhost` / `IPAddressDeny=any`。 @@ -69,7 +102,7 @@ ActionKind 决定不可降低的风险下限。模型可以把动作声明得更 - 实际执行时的隔离等级足够(`evaluate` 会用真实 isolation 重新判定); - capability 在执行时刻仍未过期; - 外部副作用已获得最终人工确认(L3 在 `evaluate` 时仍会返回 `ask`); -- capability 本身来自可信签发方——v0 控制面不校验签发链,创建者可以自铸 capability。因此 `Ready ≠ 已授权执行`,执行前仍必须经过 `evaluate` 与(未来的)broker 审批。 +- capability 本身来自可信签发方。这一条现在**取决于部署模式**:`unsigned_allowed`(默认)下创建者仍可自铸 capability;`require_signed` 下每个 capability 都必须由 keyring 中的密钥签发,但由于仓库中尚无签发组件,"谁持私钥谁就是签发方"仍是未闭合的部分。因此 `Ready ≠ 已授权执行`,执行前仍必须经过 `evaluate` 与(未来的)broker 审批。 ### 逐 action 评估隔离 @@ -91,7 +124,7 @@ ActionKind 决定不可降低的风险下限。模型可以把动作声明得更 - **`Ready → Running`**:对每个 action 以其逐 action 最低隔离重跑策略引擎,**使用请求体显式提供的 `external_side_effect_confirmed`(默认 `false`)**。任一 action 为 `Deny` 即拒绝并列出原因;任一 action 为 `Ask`(即未确认的 L3 外部副作用)则以 `external_confirmation_required` 拒绝,并列出待确认的 action。这使 `Running` 成为强制的重新授权点与 **L3 提交点**:capability 在 `Ready` 之后过期会在此被挡下,未确认的外部副作用也无法启动。确认值记入 `state_changed` 事件,与 actor 一起留痕。 - **`Verifying → Succeeded`**:要求计划中**每个** action 都有已记录的 outcome;outcome 状态必须是 `succeeded` 或 `skipped`(`failed`/`rolled_back`/`compensated` 一律拒绝),且**每条 outcome 至少携带一条 evidence**。因此"成功"是被证明的,不是被断言的。 -被门控拒绝的转换一律返回 422,`error` 码按需要的操作员动作区分:未确认 L3 外部副作用(`Ready → Running` 的 `Ask`)返回 `external_confirmation_required`;`Verifying → Succeeded` 的证据门控(缺 outcome、outcome 非成功、outcome 无 evidence)返回 `missing_evidence`;其余门控与结构性拒绝(计划未完全授权、action 被策略 Deny、非法状态转换、计划校验失败)返回 `invalid_task`。 +被门控拒绝的转换一律返回 422,`error` 码按需要的操作员动作区分:未确认 L3 外部副作用(`Ready → Running` 的 `Ask`)返回 `external_confirmation_required`;`Verifying → Succeeded` 的证据门控(缺 outcome、outcome 非成功、outcome 无 evidence)返回 `missing_evidence`;capability 未通过准入(`require_signed` 下未签名/未知 key/格式错误/被篡改)返回 `capability_not_admitted`——这要求的操作员动作是"去取一份签发方签过的授权",与"修计划"不同;其余门控与结构性拒绝(计划未完全授权、action 被策略 Deny、非法状态转换、计划校验失败)返回 `invalid_task`。未通过鉴权的请求根本到不了这一层,返回 401 `unauthorized`。 #### L3 确认的 v0 边界 @@ -136,7 +169,8 @@ ActionKind 决定不可降低的风险下限。模型可以把动作声明得更 - **确认代理**:L3 确认目前由调用方自报(见上方 v0 边界),尚无把参数摘要绑定到用户 确认的 host broker; - rollback/compensation executor; -- 用户身份、远程认证和多租户; +- **capability 签发方**:验签、keyring 与 fail-closed 拒绝已实现,但**没有任何组件签发 capability**。在受信宿主组件持有私钥(且调用方够不到)之前,签名只证明"某人签过",不证明"调用方无权自签"; +- 用户身份、远程认证和多租户:本地 bearer 令牌是**单一共享秘密**,只区分"服务账号/root"与"其他本地用户",不能区分多个调用方,因此还不能作为策略评估的 `subject`; - Task Center 图形界面。 在这些能力实现前,`taskd` 只能作为 loopback 开发服务。 diff --git a/os/files/usr/lib/systemd/system/andromeda-taskd.service b/os/files/usr/lib/systemd/system/andromeda-taskd.service index c730dda..e6d5108 100644 --- a/os/files/usr/lib/systemd/system/andromeda-taskd.service +++ b/os/files/usr/lib/systemd/system/andromeda-taskd.service @@ -4,17 +4,44 @@ After=local-fs.target [Service] Type=simple +# The service's runtime identity is whatever systemd allocates. No file in this +# repository names a uid or gid, so there is no owner to keep in sync with the +# directories below -- systemd creates both StateDirectory= and +# RuntimeDirectory= owned by this identity, and andromeda-taskd asserts at +# startup that the token directory grants nothing beyond its owner. The +# permission model is defined once, in crates/andromeda-taskd/src/auth.rs, and +# a unit test asserts that the modes declared here match those constants. DynamicUser=yes StateDirectory=andromeda-taskd +# 0700, matching auth::TOKEN_DIR_MODE and consistent with UMask=0077 below: +# the owner is the service account, and no other account may read task records. +StateDirectoryMode=0700 +# Holds the local API bearer token. andromeda-taskd generates it here on first +# start (mode 0600) and refuses to serve if this directory is group- or +# world-accessible. systemd is the only creator; taskd never mkdir's it. +RuntimeDirectory=andromeda-taskd +RuntimeDirectoryMode=0700 Environment=ANDROMEDA_LISTEN=127.0.0.1:7777 Environment=ANDROMEDA_STATE_DIR=/var/lib/andromeda-taskd/state +# There is no directive here that can disable authentication, because there is +# no such switch: andromeda_taskd::app requires an Authenticator by value and +# the type has no anonymous variant. If the token cannot be established, the +# unit fails to start rather than serving anonymously. +# Authorization: Bearer $(sudo cat /run/andromeda-taskd/token) +Environment=ANDROMEDA_AUTH_TOKEN_FILE=/run/andromeda-taskd/token +# ANDROMEDA_CAPABILITY_KEYRING is deliberately NOT set: nothing in this +# repository issues signed capabilities yet, so requiring signatures here would +# reject every request any current client can make. The daemon warns at startup +# and reports "capability_admission" on /healthz, so the posture of a running +# system is observable rather than assumed. See andromeda-threat-model.md 4.2. ExecStart=/usr/bin/andromeda-taskd Restart=on-failure RestartSec=2s # Kernel-level loopback enforcement (cgroup/BPF on packets): taskd can only ever # serve loopback regardless of ANDROMEDA_LISTEN, so a misconfigured non-loopback -# bind cannot expose the unauthenticated API to the network. This is orthogonal -# to RestrictAddressFamilies below (which filters socket() families, not routes) +# bind cannot expose the API to the network even before the bearer token is +# considered. Defence in depth, not the primary control. This is orthogonal to +# RestrictAddressFamilies below (which filters socket() families, not routes) # and does not conflict with it. See security-review.md finding #2. IPAddressAllow=localhost IPAddressDeny=any @@ -38,6 +65,9 @@ RestrictRealtime=yes RestrictSUIDSGID=yes SystemCallArchitectures=native SystemCallFilter=@system-service +# Consistent with the 0600 token file and the 0700 directories above: every +# path this service creates is owner-only. Nothing here is expected to be read +# by another account, so there is no group that this mask locks out. UMask=0077 [Install] diff --git a/os/files/usr/libexec/andromeda-ci-verify b/os/files/usr/libexec/andromeda-ci-verify index 898c083..ffa2afc 100755 --- a/os/files/usr/libexec/andromeda-ci-verify +++ b/os/files/usr/libexec/andromeda-ci-verify @@ -7,6 +7,9 @@ readonly UPDATE_ARCHIVE=/var/tmp/andromeda-v2.tar readonly UPDATE_SHA256_FW_CFG=/sys/firmware/qemu_fw_cfg/by_name/opt/andromeda/update-sha256/raw readonly UPDATE_PORT_FW_CFG=/sys/firmware/qemu_fw_cfg/by_name/opt/andromeda/update-port/raw readonly UPDATE_PORT_FALLBACK=8080 +readonly TASKD_TOKEN_FILE=/run/andromeda-taskd/token +readonly TASKD_TOKEN_ATTEMPTS=30 +readonly TASKD_TOKEN_DELAY=2 # The host binds the update server on a kernel-assigned port (see # test-install.sh) and passes it through fw_cfg. Unlike the SHA-256 -- which is @@ -40,6 +43,35 @@ emit() { systemd-cat -t andromeda-ci printf '%s\n' "${message}" } +# Set by read_taskd_token. A global rather than stdout on purpose: on failure +# that function calls emit(), and a caller's command substitution would swallow +# emit()'s output instead of letting it reach the serial console the harness +# reads. +taskd_token="" + +# The task API authenticates every request, /healthz included, so this check +# has to present the local token. andromeda-taskd generates it into systemd's +# RuntimeDirectory at startup; because the unit is Type=simple it reports +# active as soon as the process is exec'd, which can be marginally before the +# file exists. Wait for it on the same budget as the HTTP retry below. +# +# This script runs as root (the unit sets no User=), which is exactly the +# access a 0600 token in a 0700 directory is meant to require. +read_taskd_token() { + local attempt + for ((attempt = 1; attempt <= TASKD_TOKEN_ATTEMPTS; attempt++)); do + if [[ -s "${TASKD_TOKEN_FILE}" ]]; then + taskd_token="$(tr -d '[:space:]' < "${TASKD_TOKEN_FILE}")" + if [[ -n "${taskd_token}" ]]; then + return 0 + fi + fi + sleep "${TASKD_TOKEN_DELAY}" + done + emit "ANDROMEDA_TASKD_TOKEN_MISSING path=${TASKD_TOKEN_FILE}" + return 1 +} + require_runtime_health() { test -d /sys/firmware/efi test -s /var/lib/andromeda/hardware.json @@ -50,8 +82,19 @@ require_runtime_health() { test "$(getenforce)" = Enforcing systemctl is-active --quiet andromeda-taskd.service systemctl is-active --quiet sddm.service + read_taskd_token + # The token reaches curl through argv. That is acceptable *here* and only + # here: this unit is gated on andromeda.ci=1, runs on a throwaway CI VM + # with SSH disabled and no interactive users, and the token is already + # root-only on disk -- so there is no local reader for /proc to leak it to. + # Do not copy this pattern into anything a user runs. + # + # --retry only covers connection failures; a 401 fails immediately with + # curl's exit 22, so a wrong token surfaces as a hard error rather than + # sixty seconds of retries. curl --fail --silent --show-error --retry 30 --retry-delay 2 \ --retry-connrefused \ + --header "Authorization: Bearer ${taskd_token}" \ http://127.0.0.1:7777/healthz >/dev/null } diff --git a/skills/andromeda-os-engineering/references/project-map.md b/skills/andromeda-os-engineering/references/project-map.md index 3bf6861..510fac0 100644 --- a/skills/andromeda-os-engineering/references/project-map.md +++ b/skills/andromeda-os-engineering/references/project-map.md @@ -55,8 +55,10 @@ productize macOS on non-Apple hardware, or give an AI default administrator acce Asahi stack. Equivalent recovery semantics do not imply identical firmware mechanisms. - Make hardware support cohort- and evidence-based through HCM, not a generic Linux compatibility assertion. -- Keep taskd loopback-only until authentication, identity, brokered execution, and multi-tenant - boundaries exist. +- Keep taskd loopback-only until identity, brokered execution, and multi-tenant boundaries exist. + Local caller authentication now does exist (a mandatory bearer token, with an unauthenticated + listener unrepresentable in the type system), but it is a single shared secret that separates + the service account and root from other local users — not identity, and not remote auth. ## Evidence boundaries