From c9680e2e700d070811db77f827df476b24648c63 Mon Sep 17 00:00:00 2001 From: e0_7 <134906805+Daily-AC@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:31:07 -0800 Subject: [PATCH 1/9] Add scoped command tokens: a credential for one command on one device A wcs_ token is bound at issue time to one of the owner's devices and one literal command. The relay admits it only to that device with exec-only capabilities and stamps the command on the authenticated agent control channel; the agent accepts only the exact canonical exec request. Agents that do not register scoped_exec are never handed one. Revocation and expiry stop the next request and end running sessions. See docs/adr/0021-scoped-command-token.md. Co-Authored-By: Claude Sonnet 5.5 --- docs/adr/0021-scoped-command-token.md | 154 ++++++++++ docs/contract.md | 27 ++ docs/scoped-token.md | 75 +++++ docs/scoped-token.zh.md | 59 ++++ internal/agent/agent.go | 11 +- internal/agent/delegation.go | 25 +- internal/agent/scoped_e2e_test.go | 273 ++++++++++++++++++ internal/agent/scoped_test.go | 263 +++++++++++++++++ internal/catalog/commands.go | 12 + internal/delegation/delegation.go | 8 + internal/relay/bridge.go | 4 +- internal/relay/delegation_auth.go | 21 +- internal/relay/delegation_store.go | 3 + internal/relay/disable.go | 2 +- internal/relay/http.go | 4 +- internal/relay/migrate_test.go | 1 + .../relay/migrations/016_scoped_tokens.sql | 15 + internal/relay/pgstore.go | 2 +- internal/relay/relay.go | 8 +- internal/relay/scoped_pg_test.go | 265 +++++++++++++++++ internal/relay/scoped_store.go | 176 +++++++++++ internal/relay/scoped_test.go | 224 ++++++++++++++ internal/relay/scoped_user.go | 102 +++++++ internal/relay/tokenstore.go | 27 +- internal/relay/upstream.go | 5 +- internal/relay/user.go | 2 + internal/sessionauth/sessionauth.go | 22 +- main.go | 4 +- scoped_token_cmd.go | 124 ++++++++ tools/docsite/build.py | 1 + 30 files changed, 1895 insertions(+), 24 deletions(-) create mode 100644 docs/adr/0021-scoped-command-token.md create mode 100644 docs/scoped-token.md create mode 100644 docs/scoped-token.zh.md create mode 100644 internal/agent/scoped_e2e_test.go create mode 100644 internal/agent/scoped_test.go create mode 100644 internal/relay/migrations/016_scoped_tokens.sql create mode 100644 internal/relay/scoped_pg_test.go create mode 100644 internal/relay/scoped_store.go create mode 100644 internal/relay/scoped_test.go create mode 100644 internal/relay/scoped_user.go create mode 100644 scoped_token_cmd.go diff --git a/docs/adr/0021-scoped-command-token.md b/docs/adr/0021-scoped-command-token.md new file mode 100644 index 00000000..b7a11fe4 --- /dev/null +++ b/docs/adr/0021-scoped-command-token.md @@ -0,0 +1,154 @@ +# 0021 — Scoped command token: a credential that can run one command on one device + +Date: 2026-11-06 +Status: proposed (implementation on `feat/scoped-token`; not released) + +## Context + +Every credential wanctl issues today is all-or-nothing. A full token +(`wanctl_<40 hex>`) carries its owner's whole namespace: every device, every +verb (exec, push, pull, logs, shell), plus account management through the +`/u/*` endpoints. The only narrower credential is the WebFetch delegation +(`wfd_`), which is derived from a browser ticket and approved per request by a +human in the portal; it limits the *device* and the *time*, not the *action* +("grants add no action matrix", ADR 0007 and `internal/delegation`). + +That gap is a real obstacle. A phone agent, a cron job on a laptop, or a +webhook receiver often needs exactly one thing — "run the deploy script on box +A" — and today it must hold a token that can run anything on every device the +owner has. If that token leaks, or the program holding it is prompt-injected, +the blast radius is the owner's whole fleet. This is why Iris does not use +wanctl for its "ask the project" feature. + +The owner asked for it directly: *a restricted-rights token*. + +## Decision + +**A new credential kind, the scoped command token.** It is bound at issue time +to exactly one thing: *one device* and *one literal command string*. It can +run that command on that device, byte for byte, and nothing else. Version one +has this one scope and no others (no wildcards, no argument templates, no file +verbs); more scopes can be added later behind the same field. + +### Format and storage + +- Token: `wcs_<40 hex>`. The distinct prefix, like `wfd_`, lets every legacy + code path refuse it by prefix without a database lookup. +- Stored as a SHA-256 hash in the existing `tokens` table with + `kind = 'scoped'`, so expiry, revocation, listing and the "disabled account" + predicate come from the machinery that already exists and is already tested. +- Scope lives in a new table (migration 016): + `scoped_tokens(token_id PK → tokens.id, namespace, device_id, device_fp, command)`. + `device_fp` is the device's fingerprint at issue time; if the device is later + re-enrolled under a new identity the token stops resolving, exactly like a + delegation grant (`delegation_devices`). +- The raw token is returned once at issue and never stored. + +### Issue, list, revoke + +- CLI, authenticated with the owner's full token: + `wanctl scoped-token create --device D --command 'CMD' [--days N] [--label L]`, + `wanctl scoped-token list`, `wanctl scoped-token revoke ID`. + Relay endpoints: `POST /u/scoped-tokens`, `GET /u/scoped-tokens`, + `POST /u/scoped-tokens/revoke`. They use the legacy `r.auth`, which refuses + scoped and delegated tokens, so a scoped token cannot mint or revoke tokens. +- Only the owner's own devices (not devices shared *to* them) can be scoped. +- Expiry is mandatory: default 90 days, maximum 365. A forever-token is the + thing this feature exists to avoid; owners rotate instead. +- At most 50 live scoped tokens per namespace. +- Revocation reuses `tokens.revoked_at` (the same `RevokeToken` the portal's + token list already calls), so a scoped token also shows up and can be revoked + from the portal's existing token page, where it appears with `kind: scoped`. + The portal gets no new issue form in this version. + +### Enforcement: two places, and why the relay cannot be the only one + +Sessions are end-to-end TLS between the controller and the device. The relay +sees ciphertext and by design cannot read the command; putting a command filter +in the relay would mean breaking E2E. So the permission is decided in two +places that each hold part of it: + +1. **Relay (server).** Resolves the token on every dial and on every + revalidation. It admits the token only to its one pinned device + (`Access.Allows`), stamps the session with exec-only capabilities + (`sessionauth.Exec`, not `UseCapabilities`), and puts the allowed command + into the `sessionauth.Open` message it sends the *agent* over the + authenticated agent control channel. The controller cannot declare or alter + it. Any other device, `/peers` entry, account endpoint, enrollment, or + agent endpoint is refused the same way a delegated credential is + (`Resolve` rejects `kind='scoped'`; `wcs_` is refused by prefix on every + namespace-only path, including satellite relays). +2. **Agent (device).** Reads the command from the relay-stamped `Open` + message and, before any other handling of each request, requires the + request to be *exactly* the canonical form of "exec this command": kind + `exec`, `oneshot`, command equal byte for byte, and every other field zero + except `spill_after` (output handling). It is an allow-list over the whole + message, not a deny-list of known-bad fields, so a field added to the + protocol later is rejected until someone decides otherwise. Everything else + — file put/get/read/edit/write, logs, status, desktop, workspace, async + exec, elevation, a different `cwd`, a different command, a pipe or `;` + appended — gets a reject and is never executed. A changed client cannot get + around this because the agent, not the client, holds the rule. + +Each request on a scoped session also re-asks the relay `/agent/delegation-check` +(the existing per-request revalidation), so a revocation or expiry stops the +*next* request on an already-open session, and the relay's one-second lease +check closes the session itself. + +### The token does not widen the device's own policy + +The token narrows; it never grants. The command still goes through the device's +normal gate (mode, rules, approval, `bypass`), and the controller still has to +be one the device has paired. A leaked token used from an unknown machine +therefore also hits the device's pairing prompt, which is a second line. +The practical consequence for a headless use: the owner either runs the device +in a mode that allows that command, or adds an allow rule for it +(`wanctl rules`), once. + +We considered making the token itself pre-approve its command on the device. +Rejected for v1: it would let the relay, which is not trusted with device +authority (architecture, trust model layer 3), create execute permission on a +device, which is the reverse of what a *limited* token should do. + +### Old agents + +An agent that does not know about scoped tokens must never serve one, because +it would ignore the extra field and run whatever the controller asks. Two +independent fail-closed guards: + +- The new agent registers with `scoped_exec=1` (control register message and + the HTTP carrier's query). The relay refuses to dial a scoped token to an + agent that did not, with the same shape of refusal as delegation today + (`access.Delegated && !a.delegation`). The caller sees a plain refusal. +- The `Open` message for a scoped session carries `Capabilities = exec` with a + grant id. Agents from before this change validate a grant session as + `Capabilities == UseCapabilities` (`Open.ValidFor`) and silently drop + anything else, so they would not serve the session even if the relay check + were bypassed. + +An older *relay* talking to a newer agent is not a case: the token only exists +on a relay that has migration 016 and knows the kind. + +### Considered and not done in v1 + +- Binding the token to a controller key (so a leaked token alone is useless): + the schema leaves room (`Access.ControllerFingerprint` already exists), but + it needs a way to learn the holder's fingerprint before issue, which is a + second step for every use. The device's pairing gate covers the same threat + for now. +- Argument templates, wildcards, multiple commands per token, file scopes, + read-only scopes: not asked for; each would be its own design. +- A portal issue form, and switching Iris onto the token: next steps, not part + of this change. +- Relay-side command filtering: impossible under E2E, see above. + +## Consequences + +- Leaking a scoped token exposes one command on one device, for a bounded + time, behind that device's own policy and pairing. +- Full tokens and the WebFetch delegation behave exactly as before. The new + `ExecOnly` field is empty for them and every new branch is guarded on it. +- A device must run a version of wanctl that includes this change to be + reachable with a scoped token; older devices refuse. The public docs say so. +- Rollback: scoped tokens stop working the moment a relay without the code + starts (the kind is unknown to `Resolve`), and the table can stay in place. diff --git a/docs/contract.md b/docs/contract.md index eea757e4..cdf45efd 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -84,6 +84,7 @@ REFUSALS — none of these mean retry as-is: | `mcp` | — | Run wanctl as an MCP server | | `docs` | — | Read and write the portal's documentation articles | | `friends` | — | List and manage friend relationships between namespaces | +| `scoped-token` | — | Issue a token that can run one command on one device | | `share` | — | Grant another namespace the use of one of your devices | | `screenshot` | `wanctl_screenshot` | Capture a device's screen and desktop coordinates | | `act` | `wanctl_act` | Operate a Windows desktop or Android device | @@ -928,6 +929,32 @@ wanctl friends wanctl friends accept other-ns ``` +## `wanctl scoped-token` + +*Issue a token that can run one command on one device* + +A scoped token is for a program that needs exactly one thing from a device. It +runs one literal command on one device of yours and is refused everything +else: any other command or argument, a pipe or `;`, files, logs, other +devices, your account. It expires (default 90 days, at most 365) and `revoke` +stops it on its next request. It narrows access and never widens it: the +device's own mode, rules and pairing still apply, and the device must run a +wanctl version that knows scoped tokens. Hold it in WANCTL_TOKEN and run the +command with `wanctl exec --oneshot`. + +| Parameter | CLI | Type | Required | Meaning | +|---|---|---|---|---| +| — | `--device DEV` | string | no | The device of yours the token is bound to (create). | +| — | `--command CMD` | string | no | The one command it may run, one printable line, matched byte for byte (create). | +| — | `--days N` | string | no | Lifetime in days, 1-365; default 90 (create). | +| — | `--label TEXT` | string | no | A note about who holds it (create). | + +``` +wanctl scoped-token create --device home-pc --command 'uptime' --days 30 + wanctl scoped-token list + wanctl scoped-token revoke 12 +``` + ## `wanctl share` *Grant another namespace the use of one of your devices* diff --git a/docs/scoped-token.md b/docs/scoped-token.md new file mode 100644 index 00000000..d48d2b40 --- /dev/null +++ b/docs/scoped-token.md @@ -0,0 +1,75 @@ +# Scoped command tokens + +A scoped token is a credential for a program that needs exactly one thing from +a device — "restart the app on box A", "run the nightly backup" — and should +not be able to do anything else if it leaks or is tricked. + +It can run **one literal command on one device**. Everything else is refused: +another command, the same command with a different argument, a pipe or `;` +appended, another device, files, logs, a shell, your account, other tokens. +It expires, and you can revoke it at any time. + +## Issue one + +With your normal login (`wanctl login`), on any machine: + +```bash +wanctl scoped-token create --device home-pc --command 'systemctl restart app' --days 30 --label ci +``` + +The token (`wcs_…`) is printed once on stdout; the details go to stderr, so +`T=$(wanctl scoped-token create …)` captures just the token. It cannot be +shown again. + +- `--device` must be one of your own devices, not a device shared to you. +- `--command` is matched byte for byte. One printable line, up to 4096 bytes. + For anything longer, put it in a script on the device and scope the token to + running the script. +- `--days` is 1–365, default 90. There is no non-expiring scoped token. +- You can hold at most 50 live scoped tokens. + +```bash +wanctl scoped-token list # id, state, command, device, label +wanctl scoped-token revoke 12 # its next request is refused +``` + +## Use one + +Give the token to the program, set it as `WANCTL_TOKEN`, and run the command +with `--oneshot`: + +```bash +WANCTL_TOKEN=wcs_… wanctl exec --target home-pc --oneshot -- 'systemctl restart app' +``` + +Anything but that exact request is refused by the relay or by the device, and +nothing is executed. A revoked or expired token fails on the next request, +including on a session that was already open. + +## What it does not do + +A scoped token **narrows** access; it never adds any. + +- The device's own mode, rules and approvals still decide whether the command + runs. On a device that asks before running things, either approve the + command once with a rule (`wanctl rules`) or the call waits for you like any + other. +- The machine using the token still has to be paired with the device, the + same as any controller. A stolen token used from a machine the device has + never seen gets the device's pairing prompt, not a command. +- The device must run a wanctl version that supports scoped tokens. An older + device refuses the token instead of ignoring the limit. +- It is not a secret-hiding tool: whoever holds the token can run that command. + Pick commands that are safe to run on demand. + +## How it is enforced + +Sessions are end-to-end encrypted, so the relay cannot read the command. The +permission is therefore split. The relay admits the token only to its pinned +device, with exec-only rights, and tells the device which command was +authorised over the device's own authenticated channel. The device then +accepts only a request that is exactly "run this command once" and refuses +everything else, including any field it does not recognise. Changing the +client does not help: the rule is held by the device, and the relay re-checks +the token on every request. The design and the alternatives are in +ADR 0021 (`docs/adr/0021-scoped-command-token.md` in the repository). diff --git a/docs/scoped-token.zh.md b/docs/scoped-token.zh.md new file mode 100644 index 00000000..db4e4d2a --- /dev/null +++ b/docs/scoped-token.zh.md @@ -0,0 +1,59 @@ +# 限权令牌(scoped token) + +限权令牌是给「只需要从设备上拿一件事」的程序用的凭据,比如「在 A 机上重启应用」 +「跑夜间备份」。令牌泄露或程序被诱导时,它也做不了别的。 + +它只能在**一台设备上运行一条固定命令**。其余一律拒绝:换命令、同一条命令改参数、 +末尾加管道或 `;`、换设备、文件、日志、shell、你的账号、其他令牌。它会过期,你随时可以撤销。 + +## 发一把 + +用平常的登录(`wanctl login`),在任意一台机器上: + +```bash +wanctl scoped-token create --device home-pc --command 'systemctl restart app' --days 30 --label ci +``` + +令牌(`wcs_…`)只在 stdout 打印一次,说明写在 stderr,所以 +`T=$(wanctl scoped-token create …)` 拿到的只有令牌。丢了不能再看,只能重发。 + +- `--device` 必须是你自己的设备,不能是别人共享给你的。 +- `--command` 按字节逐一比对。一行可见字符,最长 4096 字节。更长的写成设备上的脚本, + 让令牌只允许运行这个脚本。 +- `--days` 取 1 到 365,默认 90。没有永不过期的限权令牌。 +- 同时有效的限权令牌最多 50 把。 + +```bash +wanctl scoped-token list # 编号、状态、命令、设备、备注 +wanctl scoped-token revoke 12 # 下一次请求就被拒 +``` + +## 使用 + +把令牌交给程序,设为 `WANCTL_TOKEN`,加 `--oneshot` 运行这条命令: + +```bash +WANCTL_TOKEN=wcs_… wanctl exec --target home-pc --oneshot -- 'systemctl restart app' +``` + +除了这一条请求,别的都会被 relay 或设备拒绝,什么都不会执行。撤销或过期的令牌在下一次请求时失效, +已经打开的会话也一样。 + +## 它不做什么 + +限权令牌只会**收窄**权限,不会增加权限。 + +- 设备自己的模式、规则和审批仍然决定命令跑不跑。设备在「每次询问」模式时,要么用规则 + (`wanctl rules`)一次性放行这条命令,要么调用会像别的请求一样等你批准。 +- 用令牌的那台机器仍然要和设备配对,和任何控制端一样。令牌被偷、从设备没见过的机器上使用, + 碰到的是设备的配对询问,不是命令执行。 +- 设备上的 wanctl 必须是支持限权令牌的版本。旧版设备会拒绝这把令牌,不会忽略限制。 +- 它不是保密工具:拿到令牌的人能随时运行那条命令,所以要选可以随时跑的命令。 + +## 怎么执行的 + +会话是端到端加密的,relay 看不到命令,所以权限分两处判断。relay 只把令牌放行到它绑定的 +那台设备,且只给执行权限,并通过设备自己的已认证通道告诉设备授权的是哪条命令。设备只接受 +恰好是「运行这条命令一次」的请求,其余全部拒绝,包括它不认识的任何字段。改客户端没用: +规则在设备手里,relay 也在每次请求时重新检查令牌。设计取舍见 +ADR 0021(仓库里的 `docs/adr/0021-scoped-command-token.md`)。 diff --git a/internal/agent/agent.go b/internal/agent/agent.go index c16273b2..ee84a7e7 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -476,7 +476,7 @@ func (a *Agent) Run(ctx context.Context) error { // cancellation (SIGTERM, `wanctl stop`) is never observed. defer wsconn.CloseOnCancel(ctx, nc)() enc := json.NewEncoder(nc) - reg := map[string]string{"op": "register", "device": a.DeviceID(), "device_id": a.DeviceID(), "name": a.opts.Name, "fingerprint": a.id.Fingerprint, "inst": a.inst, "delegation": "1"} + reg := map[string]string{"op": "register", "device": a.DeviceID(), "device_id": a.DeviceID(), "name": a.opts.Name, "fingerprint": a.id.Fingerprint, "inst": a.inst, "delegation": "1", "scoped_exec": "1"} for k, v := range a.versionFields() { reg[k] = v } @@ -611,7 +611,7 @@ func (a *Agent) handleSession(ctx context.Context, nc net.Conn, auth sessionauth a.refuse(conn, fp, hello.Name, "rejected:session", protocol.Message{Kind: protocol.KindReject, Reason: "invalid relay session capabilities"}, audit) return } - if auth.GrantID != "" && (auth.ControllerFingerprint != fp || !a.delegationActive(ctx, auth, fp)) { + if auth.GrantID != "" && (!auth.ControllerOK(fp) || !a.delegationActive(ctx, auth, fp)) { a.refuse(conn, fp, hello.Name, "rejected:delegation", protocol.Message{Kind: protocol.KindReject, Reason: "delegation is inactive or controller fingerprint does not match"}, audit) return } @@ -838,6 +838,11 @@ func (a *Agent) serveAuthorized(conn *tls.Conn, fp, peerName string, caps sessio protocol.WriteMessage(conn, protocol.Message{Kind: protocol.KindReject, Reason: "delegation inactive"}) return } + if audit.execOnly != "" && !scopedRequestAllowed(m, audit.execOnly) { + a.logSessionEvent(audit, rejectedRequestEvent(fp, peerName, m, "scoped token allows only its one command")) + protocol.WriteMessage(conn, protocol.Message{Kind: protocol.KindReject, Reason: "scoped token: only the exact command it was issued for may run"}) + continue + } if check != nil && (m.Kind == protocol.KindWorkspace || m.Kind == protocol.KindExecAsync || m.Kind == protocol.KindExecPoll || (m.Kind == protocol.KindExec && !m.OneShot)) { a.logSessionEvent(audit, rejectedRequestEvent(fp, peerName, m, "delegated execution requires a synchronous one-shot command")) protocol.WriteMessage(conn, protocol.Message{Kind: protocol.KindReject, Reason: "delegated execution requires a synchronous one-shot command"}) @@ -1251,7 +1256,7 @@ func (a *Agent) runHTTP(ctx context.Context) error { // The poll loop shares the relay transport with the sessions it spawns, so // the connection it keeps warm (and its HTTP/3 probe) serves them too. hc := &http.Client{Transport: relayhttp.Shared(), Timeout: 35 * time.Second} - q := url.Values{"device": {a.DeviceID()}, "device_id": {a.DeviceID()}, "name": {a.opts.Name}, "fp": {a.id.Fingerprint}, "inst": {a.inst}, "delegation": {"1"}} + q := url.Values{"device": {a.DeviceID()}, "device_id": {a.DeviceID()}, "name": {a.opts.Name}, "fp": {a.id.Fingerprint}, "inst": {a.inst}, "delegation": {"1"}, "scoped_exec": {"1"}} // Registration lives or dies by this loop: the relay keeps a device listed // only while its polls keep arriving. Until 2026-08-07 every failure here // was swallowed — sleep two seconds, try again, say nothing — so an agent diff --git a/internal/agent/delegation.go b/internal/agent/delegation.go index db0bd4c3..edfa6969 100644 --- a/internal/agent/delegation.go +++ b/internal/agent/delegation.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/url" + "reflect" "time" "wanctl/internal/admission" @@ -16,6 +17,26 @@ import ( type sessionAudit struct { grantID, credentialID, sessionID string workspaceCheck func() bool + // execOnly is the one command a scoped command token may run; empty for + // every other session. See docs/adr/0021-scoped-command-token.md. + execOnly string +} + +// scopedRequestAllowed is the device's half of a scoped command token: the +// request must be exactly "run this command, once, synchronously", and nothing +// else. It compares the whole message against the one canonical form instead +// of listing forbidden fields, so a field the protocol grows later is refused +// until someone decides it is safe. SpillAfter only chooses what happens to +// the output of that same command, so it is the one field left free. A cancel +// is accepted because a controller may send one after its command finished. +func scopedRequestAllowed(m protocol.Message, command string) bool { + if m.Kind == protocol.KindCancel { + return reflect.DeepEqual(m, protocol.Message{Kind: protocol.KindCancel}) + } + if m.SpillAfter < 0 { + return false + } + return reflect.DeepEqual(m, protocol.Message{Kind: protocol.KindExec, Command: command, OneShot: true, SpillAfter: m.SpillAfter}) } func (a *Agent) workspaceSessionActive(ctx context.Context, auth sessionauth.Open, fp string) bool { @@ -40,7 +61,7 @@ func auditSession(auth sessionauth.Open) sessionAudit { if auth.GrantID == "" { return sessionAudit{} } - return sessionAudit{grantID: auth.GrantID, credentialID: auth.CredentialID, sessionID: auth.Session} + return sessionAudit{grantID: auth.GrantID, credentialID: auth.CredentialID, sessionID: auth.Session, execOnly: auth.ExecOnly} } func (a *Agent) logSessionEvent(scope sessionAudit, e eventlog.Event) { @@ -88,7 +109,7 @@ func (a *Agent) delegationActive(ctx context.Context, auth sessionauth.Open, fp if auth.GrantID == "" { return true } - if ctx.Err() != nil || !auth.ValidFor(a.DeviceID()) || auth.ControllerFingerprint != fp { + if ctx.Err() != nil || !auth.ValidFor(a.DeviceID()) || !auth.ControllerOK(fp) { return false } query := url.Values{ diff --git a/internal/agent/scoped_e2e_test.go b/internal/agent/scoped_e2e_test.go new file mode 100644 index 00000000..aeb6bf4f --- /dev/null +++ b/internal/agent/scoped_e2e_test.go @@ -0,0 +1,273 @@ +package agent + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "wanctl/internal/client" + "wanctl/internal/policy" + "wanctl/internal/relay" + "wanctl/internal/transport" +) + +// The whole path with nothing faked: Postgres-backed relay, a real agent, the +// real HTTP endpoints that issue and revoke, and a real client holding the +// token that came out of them. Skipped without WANCTL_TEST_POSTGRES. +func TestScopedTokenEndToEndThroughPostgresRelay(t *testing.T) { + dsn := os.Getenv("WANCTL_TEST_POSTGRES") + if dsn == "" { + t.Skip("set WANCTL_TEST_POSTGRES to run the scoped token end-to-end test") + } + for _, carrier := range []string{"ws", "http"} { + t.Run(carrier, func(t *testing.T) { scopedEndToEnd(t, dsn, carrier) }) + } +} + +func scopedEndToEnd(t *testing.T, dsn, carrier string) { + t.Setenv("WANCTL_CONFIG_DIR", t.TempDir()) + schema := "scoped_e2e_" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", "") + base, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + if _, err := base.Exec("CREATE SCHEMA " + schema); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { base.Exec("DROP SCHEMA " + schema + " CASCADE"); base.Close() }) + u, _ := url.Parse(dsn) + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + + p, err := relay.OpenPG(u.String()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { p.Close() }) + full, err := p.IssueToken("alice", "owner", 0) + if err != nil { + t.Fatal(err) + } + r := relay.New(p) + r.SetAdmin(p) + srv := httptest.NewServer(r.Handler()) + t.Cleanup(srv.Close) + wsBase := "ws" + strings.TrimPrefix(srv.URL, "http") + + ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) + t.Cleanup(cancel) + newAgent := func(name string) *Agent { + // An agent takes its identity and device id from the config directory + // when it is built, so each one needs its own. + t.Setenv("WANCTL_CONFIG_DIR", t.TempDir()) + a, err := New(Options{RelayURL: wsBase, Token: full, Name: name, Mode: policy.ModeBypass, AutoYes: true, Transport: carrier}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(a.Close) + go a.Run(ctx) + return a + } + target, other := newAgent("target"), newAgent("other") + + cid, err := transport.IdentityFromSeed(bytes.Repeat([]byte{29}, 32), "scoped-e2e-controller") + if err != nil { + t.Fatal(err) + } + pins := transport.NewMemStore() + owner := client.NewWith(cid, pins, wsBase, full, carrier) + owner.SetLabel("owner") + for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(20 * time.Millisecond) { + if peers, err := owner.Peers(ctx); err == nil && len(peers) == 2 { + break + } + if time.Now().After(deadline) { + t.Fatal("agents never registered") + } + } + targetRoute, otherRoute := "alice/"+target.DeviceID(), "alice/"+other.DeviceID() + pins.Pin(targetRoute, target.id.Fingerprint, false) + pins.Pin(otherRoute, other.id.Fingerprint, false) + + api := func(token, method, path string, body any) (int, []byte) { + t.Helper() + var rd io.Reader + if body != nil { + raw, _ := json.Marshal(body) + rd = bytes.NewReader(raw) + } + req, _ := http.NewRequest(method, srv.URL+path, rd) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + out, _ := io.ReadAll(resp.Body) + return resp.StatusCode, out + } + + // Issue through the real endpoint, naming the device by its name. + command := "printf scoped-ok" + code, body := api(full, http.MethodPost, "/u/scoped-tokens", map[string]any{"device": "target", "command": command, "days": 1, "label": "e2e"}) + if code != 200 { + t.Fatalf("issue: %d %s", code, body) + } + var issued struct { + Token string + Info relay.ScopedToken + } + if err := json.Unmarshal(body, &issued); err != nil || !strings.HasPrefix(issued.Token, "wcs_") { + t.Fatalf("issue body: %s", body) + } + // Bad requests are refused at the door. + for name, in := range map[string]map[string]any{ + "newline": {"device": "target", "command": "a\nb"}, "days": {"device": "target", "command": "x", "days": 999}, "nothing": {}, + } { + if code, body := api(full, http.MethodPost, "/u/scoped-tokens", in); code < 400 { + t.Fatalf("%s accepted: %d %s", name, code, body) + } + } + + scoped := client.NewWith(cid, pins, wsBase, issued.Token, carrier) + scoped.SetLabel("scoped e2e") + run := func(c *client.Client, route, command string, oneshot bool) (string, error) { + var out bytes.Buffer + _, err := c.ExecTo(ctx, client.ExecRequest{Target: route, Command: command, OneShot: oneshot}, &out, io.Discard) + return out.String(), err + } + + // 1. Only the allowed command, on the allowed device. + if out, err := run(scoped, targetRoute, command, true); err != nil || out != "scoped-ok" { + t.Fatalf("scoped command: %q %v", out, err) + } + marker := filepath.Join(t.TempDir(), "must-not-exist") + for _, c := range []string{"touch " + marker, command + " x", command + "; touch " + marker, command + " | touch " + marker, command + " && touch " + marker, "", " " + command} { + if out, err := run(scoped, targetRoute, c, true); err == nil || out != "" { + t.Fatalf("command %q was not refused: %q %v", c, out, err) + } + } + if out, err := run(scoped, targetRoute, command, false); err == nil || out != "" { + t.Fatalf("persistent-shell form was not refused: %q %v", out, err) + } + if out, err := run(scoped, otherRoute, command, true); err == nil || out != "" { + t.Fatalf("scoped token reached another device of the same owner: %q %v", out, err) + } + if err := scoped.PushBytes(ctx, targetRoute, marker, []byte("x"), 0600); err == nil { + t.Fatal("scoped token wrote a file") + } + if err := scoped.Pull(ctx, targetRoute, marker, filepath.Join(t.TempDir(), "got")); err == nil { + t.Fatal("scoped token read a file") + } + if err := scoped.LogsTo(ctx, targetRoute, "", "", "", 1, io.Discard); err == nil { + t.Fatal("scoped token read logs") + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("a refused request ran: %v", err) + } + // 2. Relay interfaces beyond dialling. + for _, probe := range []struct{ method, path string }{ + {"GET", "/u/scoped-tokens"}, {"POST", "/u/scoped-tokens"}, {"GET", "/u/shares"}, {"GET", "/u/friends"}, {"POST", "/u/shares/grant"}, + } { + if code, _ := api(issued.Token, probe.method, probe.path, map[string]any{}); code != 401 && code != 403 { + t.Fatalf("scoped token on %s %s: %d", probe.method, probe.path, code) + } + } + if peers, err := scoped.Peers(ctx); err != nil || len(peers) != 1 || peers[0] != target.DeviceID() { + t.Fatalf("scoped token sees %v (%v)", peers, err) + } + // 3. The owner's full token and ordinary commands are unchanged. + if out, err := run(owner, targetRoute, "printf anything-else", true); err != nil || out != "anything-else" { + t.Fatalf("full token: %q %v", out, err) + } + if err := owner.PushBytes(ctx, otherRoute, filepath.Join(t.TempDir(), "f"), []byte("x"), 0600); err != nil { + t.Fatalf("full token push: %v", err) + } + // The list shows it; the secret is not in it. + code, body = api(full, http.MethodGet, "/u/scoped-tokens", nil) + if code != 200 || !strings.Contains(string(body), "printf scoped-ok") || strings.Contains(string(body), issued.Token) { + t.Fatalf("list: %d %s", code, body) + } + + // 4. A session already open when the token is revoked: the next request on + // it is refused. (Hold the connection by running a command that outlives + // the revoke.) + started, finished := filepath.Join(t.TempDir(), "started"), filepath.Join(t.TempDir(), "finished") + slow := "touch " + started + "; sleep 3; touch " + finished + code, body = api(full, http.MethodPost, "/u/scoped-tokens", map[string]any{"device": "target", "command": slow, "days": 1}) + var slowTok struct{ Token string } + json.Unmarshal(body, &slowTok) + if code != 200 { + t.Fatalf("issue slow: %d %s", code, body) + } + slowClient := client.NewWith(cid, pins, wsBase, slowTok.Token, carrier) + slowClient.SetLabel("scoped e2e") + done := make(chan error, 1) + go func() { _, err := run(slowClient, targetRoute, slow, true); done <- err }() + for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(20 * time.Millisecond) { + if _, err := os.Stat(started); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("the slow command never started, so the revoke below proves nothing") + } + } + var slowID struct{ Info relay.ScopedToken } + json.Unmarshal(body, &slowID) + if code, b := api(full, http.MethodPost, "/u/scoped-tokens/revoke", map[string]any{"id": slowID.Info.ID}); code != 204 { + t.Fatalf("revoke slow: %d %s", code, b) + } + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("revoking a token did not end its running session") + } + if _, err := run(slowClient, targetRoute, slow, true); err == nil { + t.Fatal("revoked token ran again") + } + time.Sleep(3500 * time.Millisecond) + if _, err := os.Stat(finished); !os.IsNotExist(err) { + t.Fatalf("the command kept running after its token was revoked: %v", err) + } + + // 5. Revoke the main token: the very next request fails. + if code, b := api(full, http.MethodPost, "/u/scoped-tokens/revoke", map[string]any{"id": issued.Info.ID}); code != 204 { + t.Fatalf("revoke: %d %s", code, b) + } + if out, err := run(scoped, targetRoute, command, true); err == nil || out != "" { + t.Fatalf("revoked token ran: %q %v", out, err) + } + if code, _ := api(full, http.MethodPost, "/u/scoped-tokens/revoke", map[string]any{"id": issued.Info.ID}); code != 404 { + t.Fatalf("second revoke: %d", code) + } + + // 6. Expiry: a fresh token whose expiry then passes. + code, body = api(full, http.MethodPost, "/u/scoped-tokens", map[string]any{"device": "target", "command": command, "days": 1}) + var exp struct { + Token string + Info relay.ScopedToken + } + json.Unmarshal(body, &exp) + expClient := client.NewWith(cid, pins, wsBase, exp.Token, carrier) + expClient.SetLabel("scoped e2e") + if out, err := run(expClient, targetRoute, command, true); code != 200 || err != nil || out != "scoped-ok" { + t.Fatalf("fresh token: %d %q %v", code, out, err) + } + if _, err := base.Exec("UPDATE "+schema+".tokens SET expires_at = now() - interval '1 second' WHERE id=$1", exp.Info.ID); err != nil { + t.Fatal(err) + } + if out, err := run(expClient, targetRoute, command, true); err == nil || out != "" { + t.Fatalf("expired token ran: %q %v", out, err) + } +} diff --git a/internal/agent/scoped_test.go b/internal/agent/scoped_test.go new file mode 100644 index 00000000..9b8d6f8f --- /dev/null +++ b/internal/agent/scoped_test.go @@ -0,0 +1,263 @@ +package agent + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "wanctl/internal/client" + "wanctl/internal/delegation" + "wanctl/internal/policy" + "wanctl/internal/protocol" + "wanctl/internal/transport" +) + +// A scoped command token (docs/adr/0021-scoped-command-token.md) through a +// real relay, a real agent and a real client, on every carrier pairing. The +// device is in bypass mode with the controller paired, so the ONLY thing +// standing between the holder and an arbitrary command is the token's scope. +type scopedFixture struct { + delegationFixture + scoped *client.Client + command string +} + +func startScopedFixture(t *testing.T, agentTransport, controllerTransport, command string, ttl time.Duration) scopedFixture { + t.Helper() + f := startDelegationFixture(t, agentTransport, controllerTransport, policy.ModeBypass, true, time.Minute) + f.tokens.issue("wcs_scoped", delegation.Access{ + Namespace: "alice", CredentialID: "scoped-credential", Delegated: true, GrantID: "sc_1", + ExpiresAt: time.Now().Add(ttl), Devices: f.tokens.access.Devices, ExecOnly: command, + }) + pins := transport.NewMemStore() + if err := pins.Pin(f.target, f.a.id.Fingerprint, false); err != nil { + t.Fatal(err) + } + // Same controller key the device already paired: the token is not what + // authorises the controller, the device's own pairing is. + c := client.NewWith(f.c.Identity(), pins, f.relayURL, "wcs_scoped", controllerTransport) + c.SetLabel("scoped test") + return scopedFixture{delegationFixture: f, scoped: c, command: command} +} + +func (f scopedFixture) run(command string) (string, error) { + var out bytes.Buffer + _, err := f.scoped.ExecTo(f.ctx, client.ExecRequest{Target: f.target, Command: command, OneShot: true}, &out, io.Discard) + return out.String(), err +} + +func TestScopedTokenRunsExactlyItsCommand(t *testing.T) { + for _, agentTransport := range []string{"ws", "http"} { + for _, controllerTransport := range []string{"ws", "http"} { + t.Run(agentTransport+"-agent/"+controllerTransport+"-controller", func(t *testing.T) { + marker := filepath.Join(t.TempDir(), "must-not-exist") + command := "printf scoped-ok" + f := startScopedFixture(t, agentTransport, controllerTransport, command, time.Minute) + + // The one thing it may do. + if out, err := f.run(command); err != nil || out != "scoped-ok" { + t.Fatalf("scoped command: %q %v", out, err) + } + // Every variation of it, each of which would create the marker or + // print the scoped output if the device ran it. + for _, other := range []string{ + "touch " + marker, + command + " extra", + command + "x", + command + "; touch " + marker, + command + " && touch " + marker, + command + " | touch " + marker, + command + "\ntouch " + marker, + command + " $(touch " + marker + ")", + command + " `touch " + marker + "`", + command + " > " + marker, + " " + command, + command + " ", + strings.ToUpper(command), + "", + } { + out, err := f.run(other) + var rejected *client.RejectError + if err == nil || !errors.As(err, &rejected) || out != "" { + t.Fatalf("command %q was not refused: out=%q err=%v", other, out, err) + } + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("a refused command ran: %v", err) + } + }) + } + } +} + +func TestScopedTokenRefusesEveryOtherVerbAndParameter(t *testing.T) { + for _, carrier := range []string{"ws", "http"} { + t.Run(carrier, func(t *testing.T) { + dir := t.TempDir() + marker := filepath.Join(dir, "must-not-exist") + command := "printf scoped-ok" + f := startScopedFixture(t, carrier, carrier, command, time.Minute) + + var out bytes.Buffer + // The same command with the parameters a holder could add. + for name, req := range map[string]client.ExecRequest{ + "cwd": {Target: f.target, Command: command, OneShot: true, Cwd: dir}, + "elevate": {Target: f.target, Command: command, OneShot: true, Elevate: true}, + "via": {Target: f.target, Command: command, OneShot: true, Elevate: true, Via: "su"}, + "shell": {Target: f.target, Command: command}, + "negspill": {Target: f.target, Command: command, OneShot: true, SpillAfter: -1}, + } { + out.Reset() + if _, err := f.scoped.ExecTo(f.ctx, req, &out, io.Discard); err == nil || out.Len() != 0 { + t.Fatalf("%s: the scoped command ran with extra parameters: out=%q err=%v", name, out.String(), err) + } + } + if _, err := f.scoped.ExecAsync(f.ctx, f.target, command, ""); err == nil { + t.Fatal("scoped token started a detached job") + } + if err := f.scoped.PushBytes(f.ctx, f.target, marker, []byte("x"), 0600); err == nil { + t.Fatal("scoped token wrote a file") + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("push created a file: %v", err) + } + seed := filepath.Join(dir, "seed.txt") + if err := os.WriteFile(seed, []byte("secret"), 0600); err != nil { + t.Fatal(err) + } + if err := f.scoped.Pull(f.ctx, f.target, seed, filepath.Join(t.TempDir(), "got.txt")); err == nil { + t.Fatal("scoped token read a file") + } + if err := f.scoped.LogsTo(f.ctx, f.target, "", "", "", 1, io.Discard); err == nil { + t.Fatal("scoped token read the device log") + } + if _, err := f.scoped.Status(f.ctx, f.target); err == nil { + t.Fatal("scoped token read device status") + } + if _, err := f.scoped.OpenConsole(f.ctx, f.target); err == nil { + t.Fatal("scoped token opened the console") + } + // And after all that the one allowed command still works: refusals + // do not poison the token. + if got, err := f.run(command); err != nil || got != "scoped-ok" { + t.Fatalf("scoped command after refusals: %q %v", got, err) + } + }) + } +} + +// The device decides from the message alone, so it must refuse a request that +// a modified client writes straight onto the wire, including fields this +// client never sets. +func TestScopedSessionRefusesAnythingButTheCanonicalRequest(t *testing.T) { + cmd := "printf scoped-ok" + canonical := protocol.Message{Kind: protocol.KindExec, Command: cmd, OneShot: true} + if !scopedRequestAllowed(canonical, cmd) { + t.Fatal("canonical request refused") + } + spill := canonical + spill.SpillAfter = 4096 + if !scopedRequestAllowed(spill, cmd) { + t.Fatal("spill request refused") + } + if !scopedRequestAllowed(protocol.Message{Kind: protocol.KindCancel}, cmd) { + t.Fatal("bare cancel refused") + } + for name, mutate := range map[string]func(*protocol.Message){ + "command": func(m *protocol.Message) { m.Command += " " }, + "kind": func(m *protocol.Message) { m.Kind = protocol.KindExecAsync }, + "oneshot": func(m *protocol.Message) { m.OneShot = false }, + "cwd": func(m *protocol.Message) { m.Cwd = "/" }, + "elevate": func(m *protocol.Message) { m.Elevate = true }, + "via": func(m *protocol.Message) { m.Via = "su" }, + "script": func(m *protocol.Message) { m.Script = "id" }, + "interp": func(m *protocol.Message) { m.Interp = "sh" }, + "action": func(m *protocol.Message) { m.Action = "x" }, + "desktop": func(m *protocol.Message) { m.Desktop = &protocol.DesktopRequest{} }, + "workspace": func(m *protocol.Message) { m.WorkspaceID = "w" }, + "request id": func(m *protocol.Message) { m.RequestID = "r" }, + "path": func(m *protocol.Message) { m.Path = "/etc/passwd" }, + "content": func(m *protocol.Message) { m.Content = "x" }, + "edits": func(m *protocol.Message) { m.Edits = []protocol.FileEdit{} }, + "job": func(m *protocol.Message) { m.JobID = "j" }, + "negspill": func(m *protocol.Message) { m.SpillAfter = -1 }, + "verdict": func(m *protocol.Message) { m.Verdict = "y" }, + "grep": func(m *protocol.Message) { m.Grep = "x" }, + "role": func(m *protocol.Message) { m.Role = "client" }, + "cancel+data": func(m *protocol.Message) { m.Kind = protocol.KindCancel }, + } { + m := canonical + mutate(&m) + if scopedRequestAllowed(m, cmd) { + t.Fatalf("%s: modified request accepted: %+v", name, m) + } + } + for _, kind := range []string{protocol.KindStatus, protocol.KindLogs, protocol.KindFilePut, protocol.KindFileGet, protocol.KindFileRead, protocol.KindFileEdit, protocol.KindFileWrite, protocol.KindDesktop, protocol.KindWorkspace, protocol.KindExecPoll, protocol.KindHello, "something-new"} { + if scopedRequestAllowed(protocol.Message{Kind: kind}, cmd) { + t.Fatalf("kind %q accepted", kind) + } + } +} + +func TestScopedTokenRevocationAndExpiryStopTheNextRequest(t *testing.T) { + for _, carrier := range []string{"ws", "http"} { + t.Run("revoke/"+carrier, func(t *testing.T) { + f := startScopedFixture(t, carrier, carrier, "printf scoped-ok", time.Minute) + if out, err := f.run(f.command); err != nil || out != "scoped-ok" { + t.Fatalf("before revoke: %q %v", out, err) + } + f.tokens.revoke() + if out, err := f.run(f.command); err == nil || out != "" { + t.Fatalf("revoked token still ran: %q %v", out, err) + } + }) + t.Run("expire/"+carrier, func(t *testing.T) { + f := startScopedFixture(t, carrier, carrier, "printf scoped-ok", 1500*time.Millisecond) + if out, err := f.run(f.command); err != nil || out != "scoped-ok" { + t.Fatalf("before expiry: %q %v", out, err) + } + time.Sleep(1700 * time.Millisecond) + if out, err := f.run(f.command); err == nil || out != "" { + t.Fatalf("expired token still ran: %q %v", out, err) + } + }) + } +} + +// The token narrows the device's policy, never widens it: with the device +// refusing the command, the scoped holder is refused too. +func TestScopedTokenStillMeetsDevicePolicy(t *testing.T) { + f := startScopedFixture(t, "ws", "ws", "printf scoped-ok", time.Minute) + f.a.setApprover(policy.DenyApprover{}) + f.a.engine.SetMode(policy.ModeNormal) + var out bytes.Buffer + _, err := f.scoped.ExecTo(f.ctx, client.ExecRequest{Target: f.target, Command: f.command, OneShot: true}, &out, io.Discard) + var rejected *client.RejectError + if !errors.As(err, &rejected) || out.Len() != 0 { + t.Fatalf("scoped token bypassed device policy: out=%q err=%v", out.String(), err) + } +} + +func TestScopedTokenBoundToWrongDeviceOrAgentIsRefused(t *testing.T) { + f := startScopedFixture(t, "ws", "ws", "printf scoped-ok", time.Minute) + other := delegation.Access{ + Namespace: "alice", CredentialID: "wrong-device", Delegated: true, GrantID: "sc_2", ExpiresAt: time.Now().Add(time.Minute), + Devices: []delegation.Device{{Namespace: "alice", ID: "some-other-device", Fingerprint: "SHA256:other"}}, ExecOnly: f.command, + } + f.tokens.issue("wcs_other", other) + pins := transport.NewMemStore() + pins.Pin(f.target, f.a.id.Fingerprint, false) + c := client.NewWith(f.c.Identity(), pins, f.relayURL, "wcs_other", "ws") + c.SetLabel("scoped test") + ctx, cancel := context.WithTimeout(f.ctx, 5*time.Second) + defer cancel() + if _, err := c.ExecTo(ctx, client.ExecRequest{Target: f.target, Command: f.command, OneShot: true}, io.Discard, io.Discard); err == nil { + t.Fatal("scoped token for another device reached this one") + } +} diff --git a/internal/catalog/commands.go b/internal/catalog/commands.go index 895764b8..135d5df9 100644 --- a/internal/catalog/commands.go +++ b/internal/catalog/commands.go @@ -491,6 +491,18 @@ var Commands = []Command{ Desc: "Sharing a device is only possible between namespaces that have agreed to it, so a friendship is the prerequisite for `wanctl share`. This lists relationships and pending requests, and sends, accepts, declines or removes them.", CLIExample: "wanctl friends\n wanctl friends add other-ns\n wanctl friends accept other-ns", }, + { + Name: "scoped-token", + Summary: "Issue a token that can run one command on one device", + Desc: "A scoped token is for a program that needs exactly one thing from a device. It runs one literal command on one device of yours and is refused everything else: any other command or argument, a pipe or `;`, files, logs, other devices, your account. It expires (default 90 days, at most 365) and `revoke` stops it on its next request. It narrows access and never widens it: the device's own mode, rules and pairing still apply, and the device must run a wanctl version that knows scoped tokens. Hold it in WANCTL_TOKEN and run the command with `wanctl exec --oneshot`.", + Params: []Param{ + {Name: "device", CLI: "--device DEV", Type: TypeString, CLIOnly: true, Desc: "The device of yours the token is bound to (create)."}, + {Name: "command", CLI: "--command CMD", Type: TypeString, CLIOnly: true, Desc: "The one command it may run, one printable line, matched byte for byte (create)."}, + {Name: "days", CLI: "--days N", Type: TypeString, CLIOnly: true, Desc: "Lifetime in days, 1-365; default 90 (create)."}, + {Name: "label", CLI: "--label TEXT", Type: TypeString, CLIOnly: true, Desc: "A note about who holds it (create)."}, + }, + CLIExample: "wanctl scoped-token create --device home-pc --command 'uptime' --days 30\n wanctl scoped-token list\n wanctl scoped-token revoke 12", + }, { Name: "share", Summary: "Grant another namespace the use of one of your devices", diff --git a/internal/delegation/delegation.go b/internal/delegation/delegation.go index 9624caa4..7a256b33 100644 --- a/internal/delegation/delegation.go +++ b/internal/delegation/delegation.go @@ -89,8 +89,16 @@ type Access struct { // of it. The job allowance is a property of the approval, so it must not // shrink as the grant runs down. GrantedMinutes int `json:"granted_minutes,omitempty"` + // ExecOnly marks a scoped command token (docs/adr/0021): the one command + // its holder may run on its one device. Empty for every other credential. + // A scoped token is a delegated credential with no controller binding. + ExecOnly string `json:"exec_only,omitempty"` } +// ScopedTokenPrefix starts every scoped command token. Like "wfd_" it lets each +// namespace-only code path refuse the credential by prefix, with no lookup. +const ScopedTokenPrefix = "wcs_" + func (a Access) Allows(target string) bool { if !a.Delegated { return true diff --git a/internal/relay/bridge.go b/internal/relay/bridge.go index 26aff70e..3886a3e7 100644 --- a/internal/relay/bridge.go +++ b/internal/relay/bridge.go @@ -208,7 +208,7 @@ func (r *Relay) handleWSDialToHTTP(w http.ResponseWriter, req *http.Request, tar http.Error(w, "device offline", http.StatusNotFound) return } - if access.Delegated && !a.delegation { + if !agentServes(access, a.delegation, a.scopedExec) { r.hmu.Unlock() http.Error(w, "device agent must be upgraded for delegated access", http.StatusConflict) return @@ -255,7 +255,7 @@ func (r *Relay) handleHDialToWS(w http.ResponseWriter, targetKey string, auth se return } - if access.Delegated && !ac.delegation { + if !agentServes(access, ac.delegation, ac.scopedExec) { http.Error(w, "device agent must be upgraded for delegated access", http.StatusConflict) return } diff --git a/internal/relay/delegation_auth.go b/internal/relay/delegation_auth.go index 2e0249bb..ba978e64 100644 --- a/internal/relay/delegation_auth.go +++ b/internal/relay/delegation_auth.go @@ -41,6 +41,14 @@ func (r *Relay) dialAccessAllowed(a delegation.Access, target string) (string, s return "", sessionauth.Open{}, "", false } auth.Capabilities = sessionauth.UseCapabilities + if a.ExecOnly != "" { + // A scoped command token runs one command and nothing else. The + // agent enforces the exact command; the relay's part is to admit + // it to its one device and to stamp the scope where the controller + // cannot reach it. + auth.Capabilities = sessionauth.Exec + auth.ExecOnly = a.ExecOnly + } auth.GrantID = a.GrantID auth.CredentialID = a.CredentialID auth.ControllerFingerprint = a.ControllerFingerprint @@ -49,6 +57,17 @@ func (r *Relay) dialAccessAllowed(a delegation.Access, target string) (string, s return key, auth, "", true } +// agentServes reports whether a device's agent may be handed this credential. +// A delegated credential needs an agent that revalidates grants; a scoped +// command token additionally needs one that enforces ExecOnly, because an agent +// that does not know the field would ignore it and run whatever it was asked. +func agentServes(access delegation.Access, delegationOK, scopedOK bool) bool { + if !access.Delegated { + return true + } + return delegationOK && (access.ExecOnly == "" || scopedOK) +} + func (r *Relay) accessPeers(a delegation.Access) map[string]any { devices, aliases := r.livePeers(a.Namespace) shared := r.sharedPeers(a.Namespace) @@ -273,7 +292,7 @@ func (r *Relay) handleAgentDelegationCheck(w http.ResponseWriter, req *http.Requ l := r.leases[req.URL.Query().Get("session")] r.leaseMu.Unlock() if l == nil || !l.access.Delegated || l.target != ns+"/"+device || l.access.GrantID != req.URL.Query().Get("grant") || - l.access.ControllerFingerprint != req.URL.Query().Get("controller_fp") || !l.valid() { + l.access.ControllerFingerprint != req.URL.Query().Get("controller_fp") && l.access.ExecOnly == "" || !l.valid() { http.Error(w, "delegation inactive", http.StatusForbidden) return } diff --git a/internal/relay/delegation_store.go b/internal/relay/delegation_store.go index 56dc5b9e..7a76fd46 100644 --- a/internal/relay/delegation_store.go +++ b/internal/relay/delegation_store.go @@ -280,6 +280,9 @@ func (p *PGStore) ResolveAccess(raw string) (delegation.Access, bool) { if expires.Valid { out.ExpiresAt = expires.Time } + if kind == "scoped" { + return p.resolveScoped(ctx, id, out) + } if kind != "delegated" { return out, true } diff --git a/internal/relay/disable.go b/internal/relay/disable.go index f88f704b..4c638f2b 100644 --- a/internal/relay/disable.go +++ b/internal/relay/disable.go @@ -45,7 +45,7 @@ func namespaceEnabled(col string) string { func (p *PGStore) TokenAccountDisabled(token string) bool { var disabled bool err := p.db.QueryRow(`SELECT EXISTS (SELECT 1 FROM tokens t JOIN users u ON u.namespace = t.namespace - WHERE t.hash = $1 AND t.revoked_at IS NULL AND t.kind <> 'delegated' + WHERE t.hash = $1 AND t.revoked_at IS NULL AND t.kind NOT IN ('delegated','scoped') AND (t.expires_at IS NULL OR t.expires_at > now()) AND u.disabled_at IS NOT NULL)`, HashToken(token)).Scan(&disabled) return err == nil && disabled } diff --git a/internal/relay/http.go b/internal/relay/http.go index 60aa0795..20533d54 100644 --- a/internal/relay/http.go +++ b/internal/relay/http.go @@ -27,6 +27,7 @@ type httpAgent struct { retired map[string]struct{} changed chan struct{} delegation bool + scopedExec bool // polls counts this device's /h/poll requests in flight. A live agent // always has one parked or is about to send the next, so none in flight // and no poll for deviceGoneAfter means the process is gone. @@ -925,6 +926,7 @@ func (r *Relay) handleHPoll(w http.ResponseWriter, req *http.Request) { } a.name = req.URL.Query().Get("name") a.delegation = req.URL.Query().Get("delegation") == "1" + a.scopedExec = req.URL.Query().Get("scoped_exec") == "1" a.lastSeen = time.Now() a.polls++ r.hagents[key] = a @@ -1026,7 +1028,7 @@ func (r *Relay) handleHDial(w http.ResponseWriter, req *http.Request) { r.handleHDialToWS(w, targetKey, auth, access, token) return } - if access.Delegated && !a.delegation { + if !agentServes(access, a.delegation, a.scopedExec) { r.hmu.Unlock() http.Error(w, "device agent must be upgraded for delegated access", http.StatusConflict) return diff --git a/internal/relay/migrate_test.go b/internal/relay/migrate_test.go index dd22549d..5763814d 100644 --- a/internal/relay/migrate_test.go +++ b/internal/relay/migrate_test.go @@ -198,6 +198,7 @@ func TestEmbeddedMigrationsApplyEveryVersion(t *testing.T) { {13, "ALTER TABLE users ADD COLUMN IF NOT EXISTS disabled_at"}, {14, "CREATE TABLE IF NOT EXISTS approval_phone"}, {15, "ADD COLUMN wanctl_version"}, + {16, "CREATE TABLE scoped_tokens"}, } if len(state.committedBodies) != len(want) { t.Fatalf("applied %d migrations, want %d: %#v", diff --git a/internal/relay/migrations/016_scoped_tokens.sql b/internal/relay/migrations/016_scoped_tokens.sql new file mode 100644 index 00000000..9cbb8cdb --- /dev/null +++ b/internal/relay/migrations/016_scoped_tokens.sql @@ -0,0 +1,15 @@ +-- Scoped command tokens (docs/adr/0021-scoped-command-token.md): a credential +-- bound to one device and one literal command. The token itself is an ordinary +-- row in tokens (kind = 'scoped'), so expiry, revocation and account disabling +-- come from the machinery tokens already have; only the scope lives here. +-- device_fp pins the device identity at issue time, so a re-enrolled device +-- under the same id does not inherit the token. +CREATE TABLE scoped_tokens ( + token_id integer PRIMARY KEY REFERENCES tokens(id), + namespace text NOT NULL, + device_id text NOT NULL, + device_fp text NOT NULL, + command text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX scoped_tokens_namespace_idx ON scoped_tokens (namespace); diff --git a/internal/relay/pgstore.go b/internal/relay/pgstore.go index 269c364f..d8a942fa 100644 --- a/internal/relay/pgstore.go +++ b/internal/relay/pgstore.go @@ -51,7 +51,7 @@ func (p *PGStore) Resolve(token string) (string, bool) { var ns string err := p.db.QueryRow( `SELECT namespace FROM tokens - WHERE hash = $1 AND revoked_at IS NULL AND kind <> 'delegated' + WHERE hash = $1 AND revoked_at IS NULL AND kind NOT IN ('delegated','scoped') AND (expires_at IS NULL OR expires_at > now()) AND `+namespaceEnabled("tokens.namespace"), HashToken(token), diff --git a/internal/relay/relay.go b/internal/relay/relay.go index 253b2f5e..9c0a1973 100644 --- a/internal/relay/relay.go +++ b/internal/relay/relay.go @@ -36,6 +36,7 @@ type agentConn struct { inst string ctrl io.Writer delegation bool + scopedExec bool // understands sessionauth.Open.ExecOnly; see agentServes mu sync.Mutex // serialize control writes kick func() // closes the control channel (an account being disabled) } @@ -341,7 +342,7 @@ func (r *Relay) auth(w http.ResponseWriter, req *http.Request) (ns string, ok bo if !ok { return "", false } - if strings.HasPrefix(token, "wfd_") { + if restrictedToken(token) { return "", false } return r.ts.Resolve(token) @@ -473,6 +474,7 @@ func (r *Relay) handleAgent(w http.ResponseWriter, req *http.Request) { Op, Device, Fingerprint, Inst, Name string DeviceID string `json:"device_id"` Delegation string `json:"delegation"` + ScopedExec string `json:"scoped_exec"` WanctlVersion json.RawMessage `json:"wanctl_version"` OpenCUVersion json.RawMessage `json:"opencu_version"` } @@ -516,7 +518,7 @@ func (r *Relay) handleAgent(w http.ResponseWriter, req *http.Request) { kickVersions := r.queueDeviceVersions(ns, reg.Device, reg.Inst, jsonVersion(reg.WanctlVersion), jsonVersion(reg.OpenCUVersion), reg.WanctlVersion != nil || reg.OpenCUVersion != nil) key := ns + "/" + reg.Device wasLive := r.deviceLive(ns, reg.Device) - ac := &agentConn{device: reg.Device, name: reg.Name, ns: ns, inst: reg.Inst, ctrl: nc, delegation: reg.Delegation == "1", kick: func() { nc.Close() }} + ac := &agentConn{device: reg.Device, name: reg.Name, ns: ns, inst: reg.Inst, ctrl: nc, delegation: reg.Delegation == "1", scopedExec: reg.ScopedExec == "1", kick: func() { nc.Close() }} r.mu.Lock() r.agents[key] = ac r.mu.Unlock() @@ -604,7 +606,7 @@ func (r *Relay) handleDial(w http.ResponseWriter, req *http.Request) { r.handleWSDialToHTTP(w, req, targetKey, auth, access, token) return } - if access.Delegated && !ac.delegation { + if !agentServes(access, ac.delegation, ac.scopedExec) { http.Error(w, "device agent must be upgraded for delegated access", http.StatusConflict) return } diff --git a/internal/relay/scoped_pg_test.go b/internal/relay/scoped_pg_test.go new file mode 100644 index 00000000..ec5e828a --- /dev/null +++ b/internal/relay/scoped_pg_test.go @@ -0,0 +1,265 @@ +package relay + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/google/uuid" + + "wanctl/internal/delegation" + "wanctl/internal/transport" +) + +// scopedPGStore is a migrated schema with accounts alice and bob, a full +// token for alice, and one enrolled device each. +func scopedPGStore(t *testing.T) (p *PGStore, full, aliceDevice, bobDevice string) { + t.Helper() + p, alice, _ := disableTestStore(t) + aliceDevice, bobDevice = uuid.NewString(), uuid.NewString() + for ns, id := range map[string]string{"alice": aliceDevice, "bob": bobDevice} { + if _, err := p.RegisterDevice(ns, id, ns+" device", transport.Fingerprint([]byte("fp-"+id))); err != nil { + t.Fatal(err) + } + } + return p, alice, aliceDevice, bobDevice +} + +func TestScopedTokenPostgresLifecycle(t *testing.T) { + p, full, device, bobDevice := scopedPGStore(t) + raw, info, err := p.IssueScopedToken("alice", device, "systemctl restart app", "ci", 0) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(raw, delegation.ScopedTokenPrefix) || info.Device != device || info.Command != "systemctl restart app" { + t.Fatalf("issue: %q %+v", raw, info) + } + if d := time.Until(info.ExpiresAt); d < 89*24*time.Hour || d > 91*24*time.Hour { + t.Fatalf("default lifetime: %v", d) + } + // Stored as a hash, never the token. + var n int + if err := p.db.QueryRow(`SELECT count(*) FROM tokens WHERE hash=$1 AND kind='scoped'`, HashToken(raw)).Scan(&n); err != nil || n != 1 { + t.Fatalf("hash row: %d %v", n, err) + } + if err := p.db.QueryRow(`SELECT count(*) FROM tokens WHERE hash=$1`, raw).Scan(&n); err != nil || n != 0 { + t.Fatalf("raw token stored: %d %v", n, err) + } + + a, ok := p.ResolveAccess(raw) + if !ok || !a.Delegated || a.ExecOnly != "systemctl restart app" || a.Namespace != "alice" || len(a.Devices) != 1 || + a.Devices[0].ID != device || a.GrantID == "" || a.ExpiresAt.IsZero() || a.ControllerFingerprint != "" { + t.Fatalf("resolve: %+v %v", a, ok) + } + if _, ok := ResolveAccess(p, raw); !ok { + t.Fatal("relay-level invariants rejected a good token") + } + if !a.Allows("alice/"+device) || a.Allows("alice/"+bobDevice) || a.Allows("bob/"+bobDevice) { + t.Fatal("scope covers the wrong devices") + } + // The namespace-only paths must not turn it into alice's own authority. + if ns, ok := p.Resolve(raw); ok { + t.Fatalf("legacy Resolve accepted a scoped token as %q", ns) + } + if p.TokenAccountDisabled(raw) { + t.Fatal("scoped token reported as an account token") + } + // Listing shows it, without the secret. + list, err := p.ListScopedTokens("alice") + if err != nil || len(list) != 1 || list[0].ID != info.ID || list[0].RevokedAt != nil { + t.Fatalf("list: %+v %v", list, err) + } + if other, _ := p.ListScopedTokens("bob"); len(other) != 0 { + t.Fatalf("bob sees alice's tokens: %+v", other) + } + // The portal's generic token list shows it too, as its own kind. + all, err := p.ListTokens("alice") + if err != nil { + t.Fatal(err) + } + var kinds []string + for _, row := range all { + kinds = append(kinds, row["kind"].(string)) + } + if !strings.Contains(strings.Join(kinds, ","), "scoped") { + t.Fatalf("portal token list lost the scoped token: %v", kinds) + } + // The full token is unaffected. + if ns, ok := p.Resolve(full); !ok || ns != "alice" { + t.Fatal("full token stopped resolving") + } + if fa, ok := p.ResolveAccess(full); !ok || fa.Delegated || fa.ExecOnly != "" { + t.Fatalf("full token access: %+v", fa) + } + + // Revocation: only the owner, only once, effective immediately. + if revoked, err := p.RevokeScopedToken("bob", info.ID); err != nil || revoked { + t.Fatalf("bob revoked alice's token: %v %v", revoked, err) + } + if _, ok := p.ResolveAccess(raw); !ok { + t.Fatal("a refused revoke still revoked") + } + if revoked, err := p.RevokeScopedToken("alice", info.ID); err != nil || !revoked { + t.Fatalf("revoke: %v %v", revoked, err) + } + if _, ok := p.ResolveAccess(raw); ok { + t.Fatal("revoked token still resolves") + } + if revoked, _ := p.RevokeScopedToken("alice", info.ID); revoked { + t.Fatal("second revoke reported success") + } + // The scoped revoke cannot be pointed at a full token. + var fullID int + if err := p.db.QueryRow(`SELECT id FROM tokens WHERE hash=$1`, HashToken(full)).Scan(&fullID); err != nil { + t.Fatal(err) + } + if revoked, _ := p.RevokeScopedToken("alice", fullID); revoked { + t.Fatal("scoped revoke revoked a full token") + } + if _, ok := p.Resolve(full); !ok { + t.Fatal("full token was revoked through the scoped endpoint") + } +} + +func TestScopedTokenPostgresExpiryDeviceIdentityAndAccount(t *testing.T) { + p, _, device, _ := scopedPGStore(t) + issue := func() (string, ScopedToken) { + t.Helper() + raw, info, err := p.IssueScopedToken("alice", device, "uptime", "", 1) + if err != nil { + t.Fatal(err) + } + if _, ok := p.ResolveAccess(raw); !ok { + t.Fatal("fresh token does not resolve") + } + return raw, info + } + + raw, info := issue() + if _, err := p.db.Exec(`UPDATE tokens SET expires_at = now() - interval '1 second' WHERE id=$1`, info.ID); err != nil { + t.Fatal(err) + } + if _, ok := p.ResolveAccess(raw); ok { + t.Fatal("expired token still resolves") + } + + // The device re-enrolled under a new identity: the token does not follow. + raw, _ = issue() + if _, err := p.db.Exec(`UPDATE devices SET fingerprint='SHA256:replaced' WHERE owner_namespace='alice' AND device_id=$1`, device); err != nil { + t.Fatal(err) + } + if _, ok := p.ResolveAccess(raw); ok { + t.Fatal("token followed a replaced device identity") + } + if _, err := p.db.Exec(`UPDATE devices SET fingerprint=$2 WHERE owner_namespace='alice' AND device_id=$1`, device, transport.Fingerprint([]byte("fp-"+device))); err != nil { + t.Fatal(err) + } + if _, ok := p.ResolveAccess(raw); !ok { + t.Fatal("token did not resume with the original identity") + } + // The device removed. + if _, err := p.db.Exec(`DELETE FROM devices WHERE owner_namespace='alice' AND device_id=$1`, device); err != nil { + t.Fatal(err) + } + if _, ok := p.ResolveAccess(raw); ok { + t.Fatal("token resolves for a deleted device") + } + + // A disabled account's tokens do not resolve; enabling restores them. + p2, _, device2, _ := scopedPGStore(t) + raw2, _, err := p2.IssueScopedToken("alice", device2, "uptime", "", 1) + if err != nil { + t.Fatal(err) + } + if _, _, err := p2.SetAccountDisabled("alice", true); err != nil { + t.Fatal(err) + } + if _, ok := p2.ResolveAccess(raw2); ok { + t.Fatal("token of a disabled account resolves") + } + if _, _, err := p2.SetAccountDisabled("alice", false); err != nil { + t.Fatal(err) + } + if _, ok := p2.ResolveAccess(raw2); !ok { + t.Fatal("token did not come back with the account") + } +} + +func TestScopedTokenPostgresRefusesBadIssues(t *testing.T) { + p, _, device, bobDevice := scopedPGStore(t) + tooLong := strings.Repeat("a", scopedMaxCommand+1) + for name, tc := range map[string]struct { + device, command, label string + days int + want error + }{ + "empty command": {device, "", "", 1, ErrScopedInvalid}, + "newline": {device, "echo a\necho b", "", 1, ErrScopedInvalid}, + "nul": {device, "echo a\x00b", "", 1, ErrScopedInvalid}, + "escape": {device, "echo \x1b[31m", "", 1, ErrScopedInvalid}, + "too long": {device, tooLong, "", 1, ErrScopedInvalid}, + "bad utf8": {device, "echo \xff", "", 1, ErrScopedInvalid}, + "days negative": {device, "uptime", "", -1, ErrScopedInvalid}, + "days over max": {device, "uptime", "", ScopedMaxDays + 1, ErrScopedInvalid}, + "label control": {device, "uptime", "a\nb", 1, ErrScopedInvalid}, + "label too long": {device, "uptime", strings.Repeat("x", scopedMaxLabel+1), 1, ErrScopedInvalid}, + "someone's device": {bobDevice, "uptime", "", 1, ErrDeviceNotFound}, + "unknown device": {uuid.NewString(), "uptime", "", 1, ErrDeviceNotFound}, + "by someone's name": {"bob device", "uptime", "", 1, ErrDeviceNotFound}, + } { + t.Run(name, func(t *testing.T) { + if raw, _, err := p.IssueScopedToken("alice", tc.device, tc.command, tc.label, tc.days); !errors.Is(err, tc.want) || raw != "" { + t.Fatalf("raw=%q err=%v want %v", raw, err, tc.want) + } + }) + } + // A tab is fine, a long-but-legal command at the limit is fine, name lookup works. + for _, ok := range []string{"printf 'a\tb'", strings.Repeat("a", scopedMaxCommand)} { + if _, _, err := p.IssueScopedToken("alice", "alice device", ok, "", 365); err != nil { + t.Fatalf("%.20q: %v", ok, err) + } + } + if n := countRows(t, p, `SELECT count(*) FROM tokens WHERE kind='scoped'`); n != 2 { + t.Fatalf("refused issues left rows behind: %d scoped tokens", n) + } + // A device with no stable id (legacy registration) cannot be scoped. + if _, err := p.db.Exec(`INSERT INTO devices(owner_namespace,device_id,display_name,fingerprint) VALUES ('alice','old-name','old-name','SHA256:x')`); err != nil { + t.Fatal(err) + } + if _, _, err := p.IssueScopedToken("alice", "old-name", "uptime", "", 1); !errors.Is(err, ErrDeviceNotFound) { + t.Fatalf("legacy device: %v", err) + } +} + +func TestScopedTokenPostgresLiveCap(t *testing.T) { + p, _, device, _ := scopedPGStore(t) + var first int + for i := 0; i < scopedMaxLive; i++ { + _, info, err := p.IssueScopedToken("alice", device, "uptime", "", 1) + if err != nil { + t.Fatalf("token %d: %v", i, err) + } + if i == 0 { + first = info.ID + } + } + if _, _, err := p.IssueScopedToken("alice", device, "uptime", "", 1); !errors.Is(err, ErrScopedLimit) { + t.Fatalf("51st token: %v", err) + } + if revoked, _ := p.RevokeScopedToken("alice", first); !revoked { + t.Fatal("revoke") + } + if _, _, err := p.IssueScopedToken("alice", device, "uptime", "", 1); err != nil { + t.Fatalf("after revoke: %v", err) + } +} + +func countRows(t *testing.T, p *PGStore, query string) int { + t.Helper() + var n int + if err := p.db.QueryRow(query).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} diff --git a/internal/relay/scoped_store.go b/internal/relay/scoped_store.go new file mode 100644 index 00000000..8bfa6ea4 --- /dev/null +++ b/internal/relay/scoped_store.go @@ -0,0 +1,176 @@ +package relay + +import ( + "context" + "database/sql" + "errors" + "strconv" + "time" + "unicode" + "unicode/utf8" + + "wanctl/internal/delegation" +) + +// Scoped command tokens: see docs/adr/0021-scoped-command-token.md. +const ( + ScopedDefaultDays = 90 + ScopedMaxDays = 365 + // scopedMaxLive bounds how many live scoped tokens one namespace may hold. + scopedMaxLive = 50 + scopedMaxCommand = 4096 + scopedMaxLabel = 200 +) + +var ( + ErrScopedInvalid = errors.New("scoped_invalid") + ErrScopedLimit = errors.New("scoped_limit") +) + +// ScopedToken is what an owner sees about a token they issued. The raw token is +// shown once, at issue, and never again. +type ScopedToken struct { + ID int `json:"id"` + Label string `json:"label"` + Device string `json:"device"` + Command string `json:"command"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` +} + +// ScopedTokenStore is optional so static-token relays keep their database-free +// behaviour; PGStore implements it. +type ScopedTokenStore interface { + IssueScopedToken(namespace, device, command, label string, days int) (string, ScopedToken, error) + ListScopedTokens(namespace string) ([]ScopedToken, error) + RevokeScopedToken(namespace string, id int) (bool, error) +} + +// validScopedCommand accepts one line of printable text. A scope the owner +// cannot read at a glance in `scoped-token list` is not a scope they can audit, +// so control characters (newlines included) are refused; put a multi-line job in +// a script and scope the call to the script. +func validScopedCommand(command string) bool { + if command == "" || len(command) > scopedMaxCommand || !utf8.ValidString(command) { + return false + } + for _, r := range command { + if unicode.IsControl(r) && r != '\t' { + return false + } + } + return true +} + +func (p *PGStore) IssueScopedToken(namespace, device, command, label string, days int) (string, ScopedToken, error) { + var out ScopedToken + if days == 0 { + days = ScopedDefaultDays + } + if days < 0 || days > ScopedMaxDays || !validScopedCommand(command) || len(label) > scopedMaxLabel || !cleanLabel(label) { + return "", out, ErrScopedInvalid + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + id, err := p.ResolveDeviceTargetStrict(namespace, device) + if err != nil { + return "", out, err + } + tx, err := p.db.BeginTx(ctx, nil) + if err != nil { + return "", out, err + } + defer tx.Rollback() + // Serialise per namespace so the live-token cap cannot be raced past. + if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "scoped-tokens:"+namespace); err != nil { + return "", out, err + } + // Only a device that carries a stable id and a fingerprint can be scoped: + // the token is pinned to that pair and stops working if either changes. + var fp string + err = tx.QueryRowContext(ctx, `SELECT COALESCE(fingerprint,'') FROM devices + WHERE owner_namespace=$1 AND device_id=$2 AND uses_device_id`, namespace, id).Scan(&fp) + if errors.Is(err, sql.ErrNoRows) || err == nil && fp == "" { + return "", out, ErrDeviceNotFound + } + if err != nil { + return "", out, err + } + var live int + if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM tokens + WHERE namespace=$1 AND kind='scoped' AND revoked_at IS NULL AND expires_at>now()`, namespace).Scan(&live); err != nil { + return "", out, err + } + if live >= scopedMaxLive { + return "", out, ErrScopedLimit + } + raw := delegation.ScopedTokenPrefix + randHex(20) + expires := time.Now().Add(time.Duration(days) * 24 * time.Hour) + var tokenID int + if err = tx.QueryRowContext(ctx, `INSERT INTO tokens(namespace,kind,hash,label,expires_at) VALUES ($1,'scoped',$2,$3,$4) RETURNING id`, + namespace, HashToken(raw), label, expires).Scan(&tokenID); err != nil { + return "", out, err + } + if _, err = tx.ExecContext(ctx, `INSERT INTO scoped_tokens(token_id,namespace,device_id,device_fp,command) VALUES ($1,$2,$3,$4,$5)`, + tokenID, namespace, id, fp, command); err != nil { + return "", out, err + } + if err = tx.Commit(); err != nil { + return "", out, err + } + return raw, ScopedToken{ID: tokenID, Label: label, Device: id, Command: command, CreatedAt: time.Now(), ExpiresAt: expires}, nil +} + +func (p *PGStore) ListScopedTokens(namespace string) ([]ScopedToken, error) { + rows, err := p.db.Query(`SELECT t.id,COALESCE(t.label,''),s.device_id,s.command,t.created_at,t.expires_at,t.revoked_at + FROM tokens t JOIN scoped_tokens s ON s.token_id=t.id WHERE t.namespace=$1 AND t.kind='scoped' ORDER BY t.id DESC`, namespace) + if err != nil { + return nil, err + } + defer rows.Close() + out := []ScopedToken{} + for rows.Next() { + var t ScopedToken + var revoked sql.NullTime + if err := rows.Scan(&t.ID, &t.Label, &t.Device, &t.Command, &t.CreatedAt, &t.ExpiresAt, &revoked); err != nil { + return nil, err + } + if revoked.Valid { + t.RevokedAt = &revoked.Time + } + out = append(out, t) + } + return out, rows.Err() +} + +// RevokeScopedToken revokes one of the namespace's own scoped tokens. It reports +// false when there is no such live scoped token, so a mistyped id is not +// mistaken for a revocation. +func (p *PGStore) RevokeScopedToken(namespace string, id int) (bool, error) { + res, err := p.db.Exec(`UPDATE tokens SET revoked_at=now() WHERE id=$1 AND namespace=$2 AND kind='scoped' AND revoked_at IS NULL`, id, namespace) + if err != nil { + return false, err + } + n, err := res.RowsAffected() + return n == 1, err +} + +// resolveScoped completes ResolveAccess for a token whose kind is "scoped". The +// token's row has already been checked for revocation, expiry and a disabled +// account; what is left is that the device it was issued for is still the same +// device. +func (p *PGStore) resolveScoped(ctx context.Context, tokenID int, out delegation.Access) (delegation.Access, bool) { + var deviceID, fp, command string + err := p.db.QueryRowContext(ctx, `SELECT s.device_id,s.device_fp,s.command FROM scoped_tokens s + JOIN devices d ON d.owner_namespace=s.namespace AND d.device_id=s.device_id AND d.uses_device_id AND d.fingerprint=s.device_fp + WHERE s.token_id=$1 AND s.namespace=$2`, tokenID, out.Namespace).Scan(&deviceID, &fp, &command) + if err != nil || out.ExpiresAt.IsZero() || command == "" { + return delegation.Access{}, false + } + out.Delegated = true + out.GrantID = "sc_" + strconv.Itoa(tokenID) + out.Devices = []delegation.Device{{Namespace: out.Namespace, ID: deviceID, Fingerprint: fp}} + out.ExecOnly = command + return out, true +} diff --git a/internal/relay/scoped_test.go b/internal/relay/scoped_test.go new file mode 100644 index 00000000..6fa750c7 --- /dev/null +++ b/internal/relay/scoped_test.go @@ -0,0 +1,224 @@ +package relay + +import ( + "net/http" + "strings" + "testing" + "time" + + "wanctl/internal/delegation" + "wanctl/internal/sessionauth" +) + +func testScopedGrant() delegation.Access { + return delegation.Access{ + Namespace: "alice", CredentialID: "7", Delegated: true, GrantID: "sc_7", + ExpiresAt: time.Now().Add(time.Hour), ExecOnly: "deploy --prod", + Devices: []delegation.Device{{Namespace: "alice", ID: "allowed", Fingerprint: "device-fp"}}, + } +} + +const scopedTestToken = "wcs_0123456789abcdef0123456789abcdef01234567" + +// Docs/adr/0021: the relay's half of a scoped command token -- it is admitted +// to its one device, with exec-only capabilities, and the command is stamped +// where the controller cannot reach. +func TestScopedAdmissionStampsExecOnlyAndOneDevice(t *testing.T) { + a := testScopedGrant() + r := New(&transportGrantStore{grants: map[string]delegation.Access{scopedTestToken: a}}) + r.agents["alice/allowed"] = &agentConn{ns: "alice", device: "allowed", name: "allowed", delegation: true, scopedExec: true} + r.agents["alice/hidden"] = &agentConn{ns: "alice", device: "hidden", name: "hidden", delegation: true, scopedExec: true} + key, auth, _, ok := r.dialAccessAllowed(a, "allowed") + if !ok || key != "alice/allowed" || auth.Capabilities != sessionauth.Exec || auth.ExecOnly != "deploy --prod" || + auth.GrantID != "sc_7" || auth.ControllerFingerprint != "" { + t.Fatalf("scoped dial: %s %+v %v", key, auth, ok) + } + auth.Session = "s" + if !auth.ValidFor("allowed") { + t.Fatal("relay stamped a session its own agent contract rejects") + } + for _, target := range []string{"hidden", "alice/hidden", "other/allowed"} { + if _, _, _, ok := r.dialAccessAllowed(a, target); ok { + t.Fatalf("scoped token reached %s", target) + } + } + for _, route := range []string{"/peers", "/h/peers"} { + rec := grantRequest(r.Handler(), "GET", route, scopedTestToken) + if rec.Code != 200 || strings.Contains(rec.Body.String(), "hidden") || !strings.Contains(rec.Body.String(), "allowed") { + t.Fatalf("discovery leak: %d %s", rec.Code, rec.Body.String()) + } + } +} + +func TestScopedAccessInvariantsAreEnforcedWhateverTheStoreSays(t *testing.T) { + good := testScopedGrant() + mutate := func(f func(*delegation.Access)) delegation.Access { a := good; f(&a); return a } + cases := map[string]struct { + token string + access delegation.Access + ok bool + }{ + "valid": {scopedTestToken, good, true}, + "not delegated": {scopedTestToken, mutate(func(a *delegation.Access) { a.Delegated = false }), false}, + "missing scope": {scopedTestToken, mutate(func(a *delegation.Access) { a.ExecOnly = "" }), false}, + "scope on a plain token": {"wanctl_plain", good, false}, + "two devices": {scopedTestToken, mutate(func(a *delegation.Access) { + a.Devices = append(a.Devices, delegation.Device{Namespace: "alice", ID: "b"}) + }), false}, + "no device": {scopedTestToken, mutate(func(a *delegation.Access) { a.Devices = nil }), false}, + "expired": {scopedTestToken, mutate(func(a *delegation.Access) { a.ExpiresAt = time.Now().Add(-time.Second) }), false}, + "no expiry": {scopedTestToken, mutate(func(a *delegation.Access) { a.ExpiresAt = time.Time{} }), false}, + "no grant id": {scopedTestToken, mutate(func(a *delegation.Access) { a.GrantID = "" }), false}, + "scoped prefix, plain": {scopedTestToken, delegation.Access{Namespace: "alice", CredentialID: "x"}, false}, + "delegated needs a binding": {"wfd_x", mutate(func(a *delegation.Access) { a.ExecOnly = "" }), false}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + s := &transportGrantStore{grants: map[string]delegation.Access{tc.token: tc.access}} + if _, ok := ResolveAccess(s, tc.token); ok != tc.ok { + t.Fatalf("ResolveAccess ok=%v want %v", ok, tc.ok) + } + }) + } + // A store that cannot express scope must not turn the token into a bare + // namespace credential. + legacy := EnvTokenStore(scopedTestToken + ":alice") + if _, ok := legacy.Resolve(scopedTestToken); ok { + t.Fatal("legacy store resolved a scoped token") + } + if _, ok := ResolveAccess(legacy, scopedTestToken); ok { + t.Fatal("legacy ResolveAccess resolved a scoped token") + } +} + +func TestScopedTokenIsRefusedOnEveryManagementAndAgentSurface(t *testing.T) { + s := &transportGrantStore{grants: map[string]delegation.Access{scopedTestToken: testScopedGrant()}} + h := New(s).Handler() + for _, route := range []struct{ method, path string }{ + {"GET", "/agent"}, {"GET", "/h/poll?device=allowed"}, {"POST", "/h/deregister?device=allowed"}, + {"GET", "/session/leaked"}, {"GET", "/agent/notify-policy?device=allowed&inst=x"}, {"POST", "/agent/events?device=allowed&inst=x"}, + {"GET", "/u/friends"}, {"POST", "/u/friends/request"}, {"GET", "/u/shares"}, {"POST", "/u/shares/grant"}, + {"POST", "/u/shares/manage"}, {"POST", "/u/shares/revoke"}, {"GET", "/u/notify"}, {"POST", "/u/notify"}, + {"GET", "/u/scoped-tokens"}, {"POST", "/u/scoped-tokens"}, {"POST", "/u/scoped-tokens/revoke"}, + {"POST", "/docs/groups"}, {"POST", "/admin/tokens/issue"}, {"POST", "/admin/enroll/mint"}, + } { + t.Run(route.method+route.path, func(t *testing.T) { + rec := grantRequest(h, route.method, route.path, scopedTestToken) + if rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden && rec.Code != http.StatusNotFound { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + if rec.Code == http.StatusNotFound { + t.Fatalf("route not refused: %d", rec.Code) + } + }) + } +} + +// /resolve is the one discovery call a controller needs before dialling; for a +// scoped token it answers about its own device and about nothing else. +func TestScopedTokenResolvesOnlyItsOwnDevice(t *testing.T) { + r := New(&transportGrantStore{grants: map[string]delegation.Access{scopedTestToken: testScopedGrant()}}) + r.agents["alice/allowed"] = &agentConn{ns: "alice", device: "allowed", name: "allowed", delegation: true, scopedExec: true} + r.agents["alice/hidden"] = &agentConn{ns: "alice", device: "hidden", name: "hidden", delegation: true, scopedExec: true} + if rec := grantRequest(r.Handler(), "GET", "/resolve?target=allowed", scopedTestToken); rec.Code != 200 || !strings.Contains(rec.Body.String(), "alice/allowed") { + t.Fatalf("own device: %d %s", rec.Code, rec.Body.String()) + } + for _, target := range []string{"hidden", "alice/hidden", "bob/allowed"} { + if rec := grantRequest(r.Handler(), "GET", "/resolve?target="+target, scopedTestToken); rec.Code == 200 { + t.Fatalf("resolved %s: %s", target, rec.Body.String()) + } + } +} + +// An agent that does not say it enforces the scope must never be handed the +// token: it would ignore the field and run whatever the controller asks. +func TestScopedTokenIsNotDialledToAnAgentThatCannotEnforceIt(t *testing.T) { + a := testScopedGrant() + s := &transportGrantStore{grants: map[string]delegation.Access{scopedTestToken: a}} + r := New(s) + for name, ac := range map[string]*agentConn{ + "delegation only (pre-scoped agent)": {ns: "alice", device: "allowed", name: "allowed", delegation: true}, + "no delegation": {ns: "alice", device: "allowed", name: "allowed"}, + } { + t.Run(name, func(t *testing.T) { + if agentServes(a, ac.delegation, ac.scopedExec) { + t.Fatal("agent without scoped_exec accepted a scoped token") + } + }) + } + if !agentServes(a, true, true) { + t.Fatal("a scoped-capable agent was refused") + } + if !agentServes(delegation.Access{Namespace: "alice"}, false, false) { + t.Fatal("a full token needs no capability") + } + if !agentServes(testTransportGrant(), true, false) { + t.Fatal("a WebFetch grant must keep working on agents without scoped_exec") + } + if agentServes(testTransportGrant(), false, false) { + t.Fatal("a WebFetch grant must still need delegation support") + } + _ = r +} + +func TestScopedValidFor(t *testing.T) { + ok := sessionauth.Open{Session: "s", CallerNamespace: "a", OwnerNamespace: "a", Device: "d", Capabilities: sessionauth.Exec, + GrantID: "sc_1", CredentialID: "1", ExpiresAt: time.Now().Add(time.Minute), ExecOnly: "x"} + if !ok.ValidFor("d") { + t.Fatal("valid scoped open rejected") + } + for name, mutate := range map[string]func(*sessionauth.Open){ + "use caps": func(o *sessionauth.Open) { o.Capabilities = sessionauth.UseCapabilities }, + "full caps": func(o *sessionauth.Open) { o.Capabilities = sessionauth.FullCapabilities }, + "read": func(o *sessionauth.Open) { o.Capabilities = sessionauth.Exec | sessionauth.Read }, + "no grant": func(o *sessionauth.Open) { o.GrantID = "" }, + "no expiry": func(o *sessionauth.Open) { o.ExpiresAt = time.Time{} }, + "expired": func(o *sessionauth.Open) { o.ExpiresAt = time.Now().Add(-time.Second) }, + "no cred id": func(o *sessionauth.Open) { o.CredentialID = "" }, + "wrong device": func(o *sessionauth.Open) { o.Device = "other" }, + } { + o := ok + mutate(&o) + if o.ValidFor("d") { + t.Fatalf("%s: accepted", name) + } + } + // What an agent from before scoped tokens sees: the same JSON minus the + // field it does not know, for a grant session. Exec-only capabilities are + // not UseCapabilities, so it drops the session. + legacy := ok + legacy.ExecOnly = "" + if legacy.ValidFor("d") { + t.Fatal("an agent that ignores exec_only would serve the session") + } +} + +// The same refusal on the wire, for both carriers an agent can be on. +func TestScopedDialAnswers409ForAgentsThatCannotEnforceIt(t *testing.T) { + for _, tc := range []struct { + name string + scopedExec bool + wantStatus func(int) bool + }{ + {"pre-scoped agent", false, func(c int) bool { return c == http.StatusConflict }}, + {"scoped-capable agent", true, func(c int) bool { return c != http.StatusConflict }}, + } { + t.Run(tc.name, func(t *testing.T) { + r := New(&transportGrantStore{grants: map[string]delegation.Access{scopedTestToken: testScopedGrant()}}) + r.agents["alice/allowed"] = &agentConn{ns: "alice", device: "allowed", name: "allowed", delegation: true, scopedExec: tc.scopedExec, ctrl: discardWriter{}} + if rec := grantRequest(r.Handler(), "GET", "/dial?target=allowed", scopedTestToken); !tc.wantStatus(rec.Code) { + t.Fatalf("ws agent: status %d %s", rec.Code, rec.Body.String()) + } + delete(r.agents, "alice/allowed") + r.hagents["alice/allowed"] = &httpAgent{ns: "alice", device: "allowed", name: "allowed", delegation: true, scopedExec: tc.scopedExec, + lastSeen: time.Now(), open: make(chan sessionauth.Open, 8), changed: make(chan struct{})} + if rec := grantRequest(r.Handler(), "POST", "/h/dial?target=allowed", scopedTestToken); !tc.wantStatus(rec.Code) { + t.Fatalf("http agent: status %d %s", rec.Code, rec.Body.String()) + } + }) + } +} + +type discardWriter struct{} + +func (discardWriter) Write(p []byte) (int, error) { return len(p), nil } diff --git a/internal/relay/scoped_user.go b/internal/relay/scoped_user.go new file mode 100644 index 00000000..cd99e1af --- /dev/null +++ b/internal/relay/scoped_user.go @@ -0,0 +1,102 @@ +package relay + +import ( + "encoding/json" + "errors" + "net/http" + "strings" +) + +// The owner-side surface of scoped command tokens +// (docs/adr/0021-scoped-command-token.md). Authenticated with a full token via +// requireUserStore, which resolves through the namespace-only path and so +// refuses scoped and delegated credentials: a scoped token cannot mint another. + +func (r *Relay) scopedStore(w http.ResponseWriter) (ScopedTokenStore, bool) { + store, ok := r.admin.(ScopedTokenStore) + if !ok { + http.Error(w, "scoped tokens need the Postgres store", http.StatusServiceUnavailable) + } + return store, ok +} + +func (r *Relay) userScopedTokens(w http.ResponseWriter, req *http.Request) { + namespace, ok := r.requireUserStore(w, req) + if !ok { + return + } + store, ok := r.scopedStore(w) + if !ok { + return + } + switch req.Method { + case http.MethodGet: + tokens, err := store.ListScopedTokens(namespace) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]any{"tokens": tokens}) + case http.MethodPost: + var body struct { + Device string `json:"device"` + Command string `json:"command"` + Label string `json:"label"` + Days int `json:"days"` + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON body", http.StatusBadRequest) + return + } + body.Device, body.Label = strings.TrimSpace(body.Device), strings.TrimSpace(body.Label) + if body.Device == "" || body.Command == "" { + http.Error(w, "device and command required", http.StatusBadRequest) + return + } + raw, info, err := store.IssueScopedToken(namespace, body.Device, body.Command, body.Label, body.Days) + switch { + case errors.Is(err, ErrScopedInvalid): + http.Error(w, "invalid command, label or days (command: one printable line up to 4096 bytes; days: 1-365)", http.StatusBadRequest) + case errors.Is(err, ErrScopedLimit): + http.Error(w, "too many live scoped tokens; revoke one first", http.StatusConflict) + case errors.Is(err, ErrDeviceNotFound): + http.Error(w, "no such device of yours that can be scoped (it must be enrolled with a device id)", http.StatusNotFound) + case err != nil: + http.Error(w, err.Error(), http.StatusConflict) + default: + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, map[string]any{"token": raw, "info": info}) + } + default: + w.Header().Set("Allow", "GET, POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (r *Relay) userScopedTokenRevoke(w http.ResponseWriter, req *http.Request) { + namespace, ok := r.requireUserStore(w, req) + if !ok || !requireMethod(w, req, http.MethodPost) { + return + } + store, ok := r.scopedStore(w) + if !ok { + return + } + var body struct { + ID int `json:"id"` + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.ID <= 0 { + http.Error(w, "id required", http.StatusBadRequest) + return + } + revoked, err := store.RevokeScopedToken(namespace, body.ID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if !revoked { + writeErrorToken(w, http.StatusNotFound, "not-found") + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/relay/tokenstore.go b/internal/relay/tokenstore.go index 9b23313b..8a311bdb 100644 --- a/internal/relay/tokenstore.go +++ b/internal/relay/tokenstore.go @@ -12,6 +12,13 @@ type TokenStore interface { Resolve(token string) (namespace string, ok bool) } +// restrictedToken reports a credential kind that carries limits a namespace-only +// lookup cannot express: a WebFetch delegation or a scoped command token. Every +// legacy path that maps a token to a bare namespace refuses these by prefix. +func restrictedToken(token string) bool { + return strings.HasPrefix(token, "wfd_") || strings.HasPrefix(token, delegation.ScopedTokenPrefix) +} + // ResolveAccess preserves device scope when a store supports delegation. The // legacy Resolve method must never resolve delegated credentials: callers of // that method are agent and account-management endpoints. @@ -23,19 +30,29 @@ func ResolveAccess(store TokenStore, token string) (delegation.Access, bool) { if !valid || a.Namespace == "" { return delegation.Access{}, false } - if strings.HasPrefix(token, "wfd_") && !a.Delegated { + if restrictedToken(token) && !a.Delegated { + return delegation.Access{}, false + } + // A scoped command token and its prefix are the same fact: one never + // appears without the other, whichever store vouched for it. + if (a.ExecOnly != "") != strings.HasPrefix(token, delegation.ScopedTokenPrefix) { return delegation.Access{}, false } if a.CredentialID == "" { a.CredentialID = HashToken(token) } - if a.Delegated && (a.GrantID == "" || a.ControllerFingerprint == "" || len(a.Devices) == 0 || a.ExpiresAt.IsZero() || !time.Now().Before(a.ExpiresAt)) { + // A scoped token is held by whichever controller the owner gave it to, + // so it has no controller binding; every other delegation has one. + if a.Delegated && (a.GrantID == "" || (a.ControllerFingerprint == "" && a.ExecOnly == "") || len(a.Devices) == 0 || a.ExpiresAt.IsZero() || !time.Now().Before(a.ExpiresAt)) { + return delegation.Access{}, false + } + if a.ExecOnly != "" && (!a.Delegated || len(a.Devices) != 1) { return delegation.Access{}, false } return a, true } - // A legacy store cannot express a WebFetch credential's restrictions. - if strings.HasPrefix(token, "wfd_") { + // A legacy store cannot express a restricted credential's limits. + if restrictedToken(token) { return delegation.Access{}, false } ns, ok := store.Resolve(token) @@ -61,7 +78,7 @@ func EnvTokenStore(spec string) TokenStore { } func (m envTokenStore) Resolve(token string) (string, bool) { - if strings.HasPrefix(token, "wfd_") { + if restrictedToken(token) { return "", false } ns, ok := m[token] diff --git a/internal/relay/upstream.go b/internal/relay/upstream.go index 590844fc..b04b1d6b 100644 --- a/internal/relay/upstream.go +++ b/internal/relay/upstream.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/json" "net/http" - "strings" "sync" "time" @@ -53,7 +52,7 @@ func NewUpstreamTokenStore(upstreamURL, secret string) *UpstreamTokenStore { // Resolve implements TokenStore. func (u *UpstreamTokenStore) Resolve(token string) (string, bool) { - if strings.HasPrefix(token, "wfd_") { + if restrictedToken(token) { return "", false } u.mu.Lock() @@ -143,7 +142,7 @@ func (u *UpstreamTokenStore) ResolveAccess(token string) (delegation.Access, boo } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { - if strings.HasPrefix(token, "wfd_") { + if restrictedToken(token) { return delegation.Access{}, false } // A pre-delegation upstream has no inspect route. Its legacy route is diff --git a/internal/relay/user.go b/internal/relay/user.go index 97478070..b3500051 100644 --- a/internal/relay/user.go +++ b/internal/relay/user.go @@ -21,6 +21,8 @@ func (r *Relay) registerUser(mux *http.ServeMux) { mux.HandleFunc("/u/shares/grant", r.userShareGrant) mux.HandleFunc("/u/shares/manage", r.userShareManage) mux.HandleFunc("/u/shares/revoke", r.userShareRevoke) + mux.HandleFunc("/u/scoped-tokens", r.userScopedTokens) + mux.HandleFunc("/u/scoped-tokens/revoke", r.userScopedTokenRevoke) mux.HandleFunc("/u/notify", r.userNotify) mux.HandleFunc("/u/notify/test", r.userNotifyTest) mux.HandleFunc("/u/devices/notify", r.userDeviceNotify) diff --git a/internal/sessionauth/sessionauth.go b/internal/sessionauth/sessionauth.go index 2851efc4..bef1b9a7 100644 --- a/internal/sessionauth/sessionauth.go +++ b/internal/sessionauth/sessionauth.go @@ -115,10 +115,30 @@ type Open struct { CredentialID string `json:"credential_id,omitempty"` ControllerFingerprint string `json:"controller_fingerprint,omitempty"` ExpiresAt time.Time `json:"expires_at,omitempty"` + // ExecOnly, when set, is the one command this session may run: a scoped + // command token's scope (docs/adr/0021-scoped-command-token.md). The relay + // stamps it here, on the authenticated agent control channel, and the agent + // refuses every request that is not exactly that command. + ExecOnly string `json:"exec_only,omitempty"` +} + +// ControllerOK reports whether the controller that completed the handshake +// (fingerprint fp) is the one this grant names. A scoped command token names +// none: it works for whoever holds it, behind the device's own pairing gate. +func (o Open) ControllerOK(fp string) bool { + return o.ExecOnly != "" || o.ControllerFingerprint == fp } func (o Open) ValidFor(device string) bool { - if o.GrantID != "" && (o.CredentialID == "" || o.ControllerFingerprint == "" || o.ExpiresAt.IsZero() || !time.Now().Before(o.ExpiresAt) || o.Capabilities != UseCapabilities) { + if o.ExecOnly != "" { + // A scoped session is a grant session that may only execute: exec-only + // capabilities, no file or log verbs. An agent from before scoped + // tokens requires UseCapabilities for a grant session and so drops this + // one instead of serving it with the scope ignored. + if o.GrantID == "" || o.CredentialID == "" || o.ExpiresAt.IsZero() || !time.Now().Before(o.ExpiresAt) || o.Capabilities != Exec { + return false + } + } else if o.GrantID != "" && (o.CredentialID == "" || o.ControllerFingerprint == "" || o.ExpiresAt.IsZero() || !time.Now().Before(o.ExpiresAt) || o.Capabilities != UseCapabilities) { return false } return o.Session != "" && o.CallerNamespace != "" && o.OwnerNamespace != "" && diff --git a/main.go b/main.go index 88789063..b0ab39b7 100644 --- a/main.go +++ b/main.go @@ -222,6 +222,8 @@ func main() { err = cmdFriends(ctx, os.Args[2:]) case "share": err = cmdShare(ctx, os.Args[2:]) + case "scoped-token": + err = cmdScopedToken(ctx, os.Args[2:]) case "start": err = cmdStart(ctx) case "stop": @@ -268,7 +270,7 @@ var relayCommands = map[string]bool{ "start": true, "login": true, "exec": true, "screenshot": true, "act": true, "push": true, "pull": true, "read": true, "edit": true, "write": true, "workspace": true, "shell": true, - "peers": true, "pair": true, "friends": true, "share": true, + "peers": true, "pair": true, "friends": true, "share": true, "scoped-token": true, "docs": true, "admin": true, } diff --git a/scoped_token_cmd.go b/scoped_token_cmd.go new file mode 100644 index 00000000..3c6739c0 --- /dev/null +++ b/scoped_token_cmd.go @@ -0,0 +1,124 @@ +package main + +import ( + "context" + "flag" + "fmt" + "net/http" + "os" + "strconv" + "time" + + "wanctl/internal/relay" +) + +// Scoped command tokens: a credential that can run one command on one device. +// See docs/adr/0021-scoped-command-token.md. + +const scopedTokenHelp = `wanctl scoped-token subcommands: + scoped-token create --device DEV --command CMD [--days N] [--label TEXT] + scoped-token list + scoped-token revoke ID` + +func cmdScopedToken(ctx context.Context, args []string) error { + if len(args) == 0 { + fmt.Println(scopedTokenHelp) + return nil + } + switch args[0] { + case "create": + return scopedTokenCreate(ctx, args[1:]) + case "list": + if len(args) != 1 { + return fmt.Errorf("usage: wanctl scoped-token list") + } + return scopedTokenList(ctx) + case "revoke": + return scopedTokenRevoke(ctx, args[1:]) + case "-h", "--help", "help": + fmt.Println(scopedTokenHelp) + return nil + default: + return fmt.Errorf("unknown subcommand %q\n%s", args[0], scopedTokenHelp) + } +} + +func scopedTokenCreate(ctx context.Context, args []string) error { + fs := withHelp(flag.NewFlagSet("scoped-token create", flag.ContinueOnError)) + device := fs.String("device", "", "device of yours the token may use") + command := fs.String("command", "", "the one command it may run, exactly as the shell will see it") + days := fs.Int("days", relay.ScopedDefaultDays, fmt.Sprintf("lifetime in days (1-%d)", relay.ScopedMaxDays)) + label := fs.String("label", "", "a note for yourself about who holds it") + if err := fs.Parse(args); err != nil { + return err + } + if *device == "" || *command == "" || fs.NArg() != 0 { + return fmt.Errorf("usage: wanctl scoped-token create --device DEV --command CMD [--days N] [--label TEXT]") + } + var result struct { + Token string `json:"token"` + Info relay.ScopedToken `json:"info"` + } + err := userRequest(ctx, http.MethodPost, "/u/scoped-tokens", map[string]any{ + "device": *device, "command": *command, "label": *label, "days": *days, + }, &result) + if err != nil { + return err + } + // Only the token goes to stdout, so `T=$(wanctl scoped-token create ...)` works. + fmt.Println(result.Token) + fmt.Fprintf(os.Stderr, "Scoped token #%d for device %s, valid until %s. Shown once; it cannot be recovered.\n"+ + "It can run exactly this and nothing else:\n %s\n"+ + "Use it: WANCTL_TOKEN= wanctl exec --target %s --oneshot -- %s\n"+ + "The device's own rules and pairing still apply to it. Revoke: wanctl scoped-token revoke %d\n", + result.Info.ID, result.Info.Device, result.Info.ExpiresAt.Local().Format("2006-01-02"), result.Info.Command, + result.Info.Device, strconv.Quote(result.Info.Command), result.Info.ID) + return nil +} + +func scopedTokenList(ctx context.Context) error { + var result struct { + Tokens []relay.ScopedToken `json:"tokens"` + } + if err := userRequest(ctx, http.MethodGet, "/u/scoped-tokens", nil, &result); err != nil { + return err + } + if len(result.Tokens) == 0 { + fmt.Println("(no scoped tokens)") + return nil + } + for _, t := range result.Tokens { + state := "valid until " + t.ExpiresAt.Local().Format("2006-01-02") + switch { + case t.RevokedAt != nil: + state = "revoked" + case time.Now().After(t.ExpiresAt): + state = "expired" + } + label := "" + if t.Label != "" { + label = " [" + t.Label + "]" + } + fmt.Printf("#%-4d %-18s %s device %s%s\n", t.ID, state, strconv.Quote(t.Command), t.Device, label) + } + return nil +} + +func scopedTokenRevoke(ctx context.Context, args []string) error { + if len(args) != 1 { + return fmt.Errorf("usage: wanctl scoped-token revoke ID") + } + id, err := strconv.Atoi(args[0]) + if err != nil || id <= 0 { + return fmt.Errorf("usage: wanctl scoped-token revoke ID") + } + err = userRequest(ctx, http.MethodPost, "/u/scoped-tokens/revoke", map[string]int{"id": id}, nil) + if isRelayError(err, http.StatusNotFound, "not-found") { + return fmt.Errorf("no live scoped token #%d of yours (see `wanctl scoped-token list`)", id) + } + if err != nil { + return err + } + fmt.Printf("Revoked scoped token #%d. Its next request is refused.\n", id) + return nil +} diff --git a/tools/docsite/build.py b/tools/docsite/build.py index e7ec028e..b358feec 100644 --- a/tools/docsite/build.py +++ b/tools/docsite/build.py @@ -119,6 +119,7 @@ "architecture.md", "device-identity.md", "webfetch.md", + "scoped-token.md", "self-hosting.md", "environment.md", "release-signing.md", From 081e69c3cfe92735927e8330d9a97484ee06d1f0 Mon Sep 17 00:00:00 2001 From: e0_7 <134906805+Daily-AC@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:32:57 -0800 Subject: [PATCH 2/9] docs: scoped token needs the device identity pinned once Co-Authored-By: Claude Sonnet 5.5 --- docs/scoped-token.md | 4 ++++ docs/scoped-token.zh.md | 3 +++ 2 files changed, 7 insertions(+) diff --git a/docs/scoped-token.md b/docs/scoped-token.md index d48d2b40..810952d2 100644 --- a/docs/scoped-token.md +++ b/docs/scoped-token.md @@ -42,6 +42,10 @@ with `--oneshot`: WANCTL_TOKEN=wcs_… wanctl exec --target home-pc --oneshot -- 'systemctl restart app' ``` +The machine using the token must trust the device's identity once, like any +controller: `wanctl trust server --target home-pc --fingerprint SHA256:…` with +the fingerprint the device prints when its agent starts. + Anything but that exact request is refused by the relay or by the device, and nothing is executed. A revoked or expired token fails on the next request, including on a session that was already open. diff --git a/docs/scoped-token.zh.md b/docs/scoped-token.zh.md index db4e4d2a..4c59796b 100644 --- a/docs/scoped-token.zh.md +++ b/docs/scoped-token.zh.md @@ -36,6 +36,9 @@ wanctl scoped-token revoke 12 # 下一次请求就被拒 WANCTL_TOKEN=wcs_… wanctl exec --target home-pc --oneshot -- 'systemctl restart app' ``` +用令牌的那台机器和任何控制端一样,要先确认一次设备身份: +`wanctl trust server --target home-pc --fingerprint SHA256:…`,指纹是设备上 agent 启动时打印的那一个。 + 除了这一条请求,别的都会被 relay 或设备拒绝,什么都不会执行。撤销或过期的令牌在下一次请求时失效, 已经打开的会话也一样。 From c268b7df4d224d39c383006d92d06f0132a5cffd Mon Sep 17 00:00:00 2001 From: e0_7 <134906805+Daily-AC@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:34:48 -0800 Subject: [PATCH 3/9] Portal token list: show a scoped token as one command, not account access Co-Authored-By: Claude Sonnet 5.5 --- internal/portal/web/app.js | 5 +++-- internal/relay/admin.go | 16 ++++++++++------ internal/relay/scoped_pg_test.go | 3 +++ 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/internal/portal/web/app.js b/internal/portal/web/app.js index 2087aa88..4beffffb 100644 --- a/internal/portal/web/app.js +++ b/internal/portal/web/app.js @@ -95,7 +95,7 @@ accept: 'Accept', decline: 'Decline', withdraw: 'Withdraw', shareDevice: 'Share a device', shareGrantee: 'Grant to', sharePerms: 'Permissions', share: 'Share', never2: 'never', revoked: 'revoked', active: 'active', expired: 'expired', - delegated: 'Temporary device use', allDevices: 'Account access', + delegated: 'Temporary device use', allDevices: 'Account access', scoped: 'One command only', copied: 'Copied', copy: 'Copy', saved: 'Saved', cleared: 'Cleared', sent: 'Test delivered', aliasNone: 'no alias', @@ -261,7 +261,7 @@ accept: '接受', decline: '拒绝', withdraw: '撤回', shareDevice: '共享一台设备', shareGrantee: '授权给', sharePerms: '权限', share: '授权', never2: '永不', revoked: '已吊销', active: '有效', expired: '已过期', - delegated: '临时设备使用权', allDevices: '账号访问权限', + delegated: '临时设备使用权', allDevices: '账号访问权限', scoped: '仅限一条命令', copied: '已复制', copy: '复制', saved: '已保存', cleared: '已清除', sent: '测试通知已送达', aliasNone: '未设置', @@ -1569,6 +1569,7 @@ }).join(', '); var delegated = x.kind === 'delegated' || !!x.grant_id; var scopeLine = delegated ? t().delegated + (scope ? ' · ' + scope : '') : t().allDevices; + if (x.kind === 'scoped') scopeLine = t().scoped + ' · ' + (x.scoped_command || '') + ' · ' + (x.scoped_device || ''); return '' + esc(x.label || '—') + '' + esc(scopeLine) + '' + '' + esc(fmt(x.created_at)) + '' + diff --git a/internal/relay/admin.go b/internal/relay/admin.go index f867d034..9fa7bb8f 100644 --- a/internal/relay/admin.go +++ b/internal/relay/admin.go @@ -1051,9 +1051,9 @@ func (p *PGStore) IssueToken(namespace, label string, days int) (string, error) func (p *PGStore) ListTokens(namespace string) ([]map[string]any, error) { rows, err := p.db.Query( `SELECT t.id, COALESCE(t.label,''), t.created_at, t.expires_at, t.revoked_at, - t.kind,COALESCE(r.id,''),COALESCE((SELECT json_agg(json_build_object('namespace',d.namespace,'id',d.device_id,'fingerprint',d.fingerprint) ORDER BY d.device_id) + t.kind,COALESCE(r.id,''),COALESCE(s.command,''),COALESCE(s.device_id,''),COALESCE((SELECT json_agg(json_build_object('namespace',d.namespace,'id',d.device_id,'fingerprint',d.fingerprint) ORDER BY d.device_id) FROM delegation_devices d WHERE d.grant_id=r.id),'[]'::json) - FROM tokens t LEFT JOIN delegation_requests r ON r.token_id=t.id WHERE t.namespace = $1 ORDER BY t.id DESC`, namespace) + FROM tokens t LEFT JOIN delegation_requests r ON r.token_id=t.id LEFT JOIN scoped_tokens s ON s.token_id=t.id WHERE t.namespace = $1 ORDER BY t.id DESC`, namespace) if err != nil { return nil, err } @@ -1061,22 +1061,26 @@ func (p *PGStore) ListTokens(namespace string) ([]map[string]any, error) { out := []map[string]any{} for rows.Next() { var id int - var label, kind, grantID string + var label, kind, grantID, scopedCommand, scopedDevice string var devicesJSON []byte var devices []map[string]string var created time.Time var expires, revoked sql.NullTime - if err := rows.Scan(&id, &label, &created, &expires, &revoked, &kind, &grantID, &devicesJSON); err != nil { + if err := rows.Scan(&id, &label, &created, &expires, &revoked, &kind, &grantID, &scopedCommand, &scopedDevice, &devicesJSON); err != nil { return nil, err } if err := json.Unmarshal(devicesJSON, &devices); err != nil { return nil, err } - out = append(out, map[string]any{ + row := map[string]any{ "id": id, "label": label, "created_at": created, "expires_at": nullTime(expires), "revoked_at": nullTime(revoked), "kind": kind, "grant_id": grantID, "devices": devices, - }) + } + if scopedCommand != "" { + row["scoped_command"], row["scoped_device"] = scopedCommand, scopedDevice + } + out = append(out, row) } return out, rows.Err() } diff --git a/internal/relay/scoped_pg_test.go b/internal/relay/scoped_pg_test.go index ec5e828a..c8814204 100644 --- a/internal/relay/scoped_pg_test.go +++ b/internal/relay/scoped_pg_test.go @@ -81,6 +81,9 @@ func TestScopedTokenPostgresLifecycle(t *testing.T) { var kinds []string for _, row := range all { kinds = append(kinds, row["kind"].(string)) + if row["kind"] == "scoped" && (row["scoped_command"] != "systemctl restart app" || row["scoped_device"] != device) { + t.Fatalf("portal token list lost the scope: %+v", row) + } } if !strings.Contains(strings.Join(kinds, ","), "scoped") { t.Fatalf("portal token list lost the scoped token: %v", kinds) From e7b69fb312e5977542067e050ace65e66edaa237 Mon Sep 17 00:00:00 2001 From: e0_7 <134906805+Daily-AC@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:06:00 -0800 Subject: [PATCH 4/9] Store scoped tokens as kind delegated so a relay rollback cannot widen them An older relay's namespace-only lookup excludes kind 'delegated' only; a new kind would have resolved as a full account token. A scoped_tokens row now marks the token scoped. Found in the Codex review. Co-Authored-By: Claude Sonnet 5.5 --- docs/adr/0021-scoped-command-token.md | 23 +++++++++++++------ internal/relay/admin.go | 1 + internal/relay/delegation_store.go | 9 ++++---- internal/relay/disable.go | 2 +- .../relay/migrations/016_scoped_tokens.sql | 2 +- internal/relay/pgstore.go | 2 +- internal/relay/scoped_pg_test.go | 11 +++++++-- internal/relay/scoped_store.go | 15 ++++++++---- 8 files changed, 44 insertions(+), 21 deletions(-) diff --git a/docs/adr/0021-scoped-command-token.md b/docs/adr/0021-scoped-command-token.md index b7a11fe4..d982a228 100644 --- a/docs/adr/0021-scoped-command-token.md +++ b/docs/adr/0021-scoped-command-token.md @@ -34,9 +34,14 @@ verbs); more scopes can be added later behind the same field. - Token: `wcs_<40 hex>`. The distinct prefix, like `wfd_`, lets every legacy code path refuse it by prefix without a database lookup. -- Stored as a SHA-256 hash in the existing `tokens` table with - `kind = 'scoped'`, so expiry, revocation, listing and the "disabled account" - predicate come from the machinery that already exists and is already tested. +- Stored as a SHA-256 hash in the existing `tokens` table, so expiry, + revocation, listing and the "disabled account" predicate come from the + machinery that already exists and is already tested. Its `kind` is + `delegated`, the kind earlier releases already refuse on every + namespace-only lookup (`Resolve`, `TokenAccountDisabled`); a row in + `scoped_tokens` is what marks it scoped. A new kind of its own would have + been accepted as a full account token by the old queries (`kind <> + 'delegated'`) the moment a relay was rolled back. - Scope lives in a new table (migration 016): `scoped_tokens(token_id PK → tokens.id, namespace, device_id, device_fp, command)`. `device_fp` is the device's fingerprint at issue time; if the device is later @@ -58,7 +63,8 @@ verbs); more scopes can be added later behind the same field. - At most 50 live scoped tokens per namespace. - Revocation reuses `tokens.revoked_at` (the same `RevokeToken` the portal's token list already calls), so a scoped token also shows up and can be revoked - from the portal's existing token page, where it appears with `kind: scoped`. + from the portal's existing token page, where it appears as "one command + only" with its command and device. The portal gets no new issue form in this version. ### Enforcement: two places, and why the relay cannot be the only one @@ -76,7 +82,7 @@ places that each hold part of it: authenticated agent control channel. The controller cannot declare or alter it. Any other device, `/peers` entry, account endpoint, enrollment, or agent endpoint is refused the same way a delegated credential is - (`Resolve` rejects `kind='scoped'`; `wcs_` is refused by prefix on every + (`Resolve` rejects kind `delegated`; `wcs_` is refused by prefix on every namespace-only path, including satellite relays). 2. **Agent (device).** Reads the command from the relay-stamped `Open` message and, before any other handling of each request, requires the @@ -150,5 +156,8 @@ on a relay that has migration 016 and knows the kind. `ExecOnly` field is empty for them and every new branch is guarded on it. - A device must run a version of wanctl that includes this change to be reachable with a scoped token; older devices refuse. The public docs say so. -- Rollback: scoped tokens stop working the moment a relay without the code - starts (the kind is unknown to `Resolve`), and the table can stay in place. +- Rollback: a relay binary without this code cannot resolve a scoped token + (kind `delegated` with no delegation request) and refuses it on the + namespace-only paths, so the tokens go dead rather than gaining account + rights; the table can stay in place. A regression test runs the old + `Resolve` query against a scoped token. diff --git a/internal/relay/admin.go b/internal/relay/admin.go index 9fa7bb8f..b7d9148d 100644 --- a/internal/relay/admin.go +++ b/internal/relay/admin.go @@ -1078,6 +1078,7 @@ func (p *PGStore) ListTokens(namespace string) ([]map[string]any, error) { "kind": kind, "grant_id": grantID, "devices": devices, } if scopedCommand != "" { + row["kind"] = "scoped" row["scoped_command"], row["scoped_device"] = scopedCommand, scopedDevice } out = append(out, row) diff --git a/internal/relay/delegation_store.go b/internal/relay/delegation_store.go index 7a76fd46..46f930d2 100644 --- a/internal/relay/delegation_store.go +++ b/internal/relay/delegation_store.go @@ -269,10 +269,11 @@ func (p *PGStore) ResolveAccess(raw string) (delegation.Access, bool) { var out delegation.Access var id int var kind, grantID string + var scoped bool var expires sql.NullTime - err := p.db.QueryRowContext(ctx, `SELECT t.id,t.namespace,t.kind,t.expires_at,COALESCE(r.id,'') - FROM tokens t LEFT JOIN delegation_requests r ON r.token_id=t.id - WHERE t.hash=$1 AND t.revoked_at IS NULL AND (t.expires_at IS NULL OR t.expires_at>now()) AND `+namespaceEnabled("t.namespace"), HashToken(raw)).Scan(&id, &out.Namespace, &kind, &expires, &grantID) + err := p.db.QueryRowContext(ctx, `SELECT t.id,t.namespace,t.kind,t.expires_at,COALESCE(r.id,''),s.token_id IS NOT NULL + FROM tokens t LEFT JOIN delegation_requests r ON r.token_id=t.id LEFT JOIN scoped_tokens s ON s.token_id=t.id + WHERE t.hash=$1 AND t.revoked_at IS NULL AND (t.expires_at IS NULL OR t.expires_at>now()) AND `+namespaceEnabled("t.namespace"), HashToken(raw)).Scan(&id, &out.Namespace, &kind, &expires, &grantID, &scoped) if err != nil { return delegation.Access{}, false } @@ -280,7 +281,7 @@ func (p *PGStore) ResolveAccess(raw string) (delegation.Access, bool) { if expires.Valid { out.ExpiresAt = expires.Time } - if kind == "scoped" { + if scoped { return p.resolveScoped(ctx, id, out) } if kind != "delegated" { diff --git a/internal/relay/disable.go b/internal/relay/disable.go index 4c638f2b..f88f704b 100644 --- a/internal/relay/disable.go +++ b/internal/relay/disable.go @@ -45,7 +45,7 @@ func namespaceEnabled(col string) string { func (p *PGStore) TokenAccountDisabled(token string) bool { var disabled bool err := p.db.QueryRow(`SELECT EXISTS (SELECT 1 FROM tokens t JOIN users u ON u.namespace = t.namespace - WHERE t.hash = $1 AND t.revoked_at IS NULL AND t.kind NOT IN ('delegated','scoped') + WHERE t.hash = $1 AND t.revoked_at IS NULL AND t.kind <> 'delegated' AND (t.expires_at IS NULL OR t.expires_at > now()) AND u.disabled_at IS NOT NULL)`, HashToken(token)).Scan(&disabled) return err == nil && disabled } diff --git a/internal/relay/migrations/016_scoped_tokens.sql b/internal/relay/migrations/016_scoped_tokens.sql index 9cbb8cdb..219f00c8 100644 --- a/internal/relay/migrations/016_scoped_tokens.sql +++ b/internal/relay/migrations/016_scoped_tokens.sql @@ -1,6 +1,6 @@ -- Scoped command tokens (docs/adr/0021-scoped-command-token.md): a credential -- bound to one device and one literal command. The token itself is an ordinary --- row in tokens (kind = 'scoped'), so expiry, revocation and account disabling +-- row in tokens (kind 'delegated', which earlier relays refuse; this table is what marks it scoped), so expiry, revocation and account disabling -- come from the machinery tokens already have; only the scope lives here. -- device_fp pins the device identity at issue time, so a re-enrolled device -- under the same id does not inherit the token. diff --git a/internal/relay/pgstore.go b/internal/relay/pgstore.go index d8a942fa..269c364f 100644 --- a/internal/relay/pgstore.go +++ b/internal/relay/pgstore.go @@ -51,7 +51,7 @@ func (p *PGStore) Resolve(token string) (string, bool) { var ns string err := p.db.QueryRow( `SELECT namespace FROM tokens - WHERE hash = $1 AND revoked_at IS NULL AND kind NOT IN ('delegated','scoped') + WHERE hash = $1 AND revoked_at IS NULL AND kind <> 'delegated' AND (expires_at IS NULL OR expires_at > now()) AND `+namespaceEnabled("tokens.namespace"), HashToken(token), diff --git a/internal/relay/scoped_pg_test.go b/internal/relay/scoped_pg_test.go index c8814204..b466ee15 100644 --- a/internal/relay/scoped_pg_test.go +++ b/internal/relay/scoped_pg_test.go @@ -40,7 +40,7 @@ func TestScopedTokenPostgresLifecycle(t *testing.T) { } // Stored as a hash, never the token. var n int - if err := p.db.QueryRow(`SELECT count(*) FROM tokens WHERE hash=$1 AND kind='scoped'`, HashToken(raw)).Scan(&n); err != nil || n != 1 { + if err := p.db.QueryRow(`SELECT count(*) FROM tokens t JOIN scoped_tokens s ON s.token_id=t.id WHERE t.hash=$1 AND t.kind='delegated'`, HashToken(raw)).Scan(&n); err != nil || n != 1 { t.Fatalf("hash row: %d %v", n, err) } if err := p.db.QueryRow(`SELECT count(*) FROM tokens WHERE hash=$1`, raw).Scan(&n); err != nil || n != 0 { @@ -65,6 +65,13 @@ func TestScopedTokenPostgresLifecycle(t *testing.T) { if p.TokenAccountDisabled(raw) { t.Fatal("scoped token reported as an account token") } + // Rolling the relay binary back must not turn it into an account token: the + // namespace-only lookup of every earlier release is exactly this query. + var legacyNS string + if err := p.db.QueryRow(`SELECT namespace FROM tokens WHERE hash=$1 AND revoked_at IS NULL AND kind <> 'delegated' + AND (expires_at IS NULL OR expires_at > now())`, HashToken(raw)).Scan(&legacyNS); err == nil { + t.Fatalf("an older relay's Resolve query would accept the scoped token as %q", legacyNS) + } // Listing shows it, without the secret. list, err := p.ListScopedTokens("alice") if err != nil || len(list) != 1 || list[0].ID != info.ID || list[0].RevokedAt != nil { @@ -223,7 +230,7 @@ func TestScopedTokenPostgresRefusesBadIssues(t *testing.T) { t.Fatalf("%.20q: %v", ok, err) } } - if n := countRows(t, p, `SELECT count(*) FROM tokens WHERE kind='scoped'`); n != 2 { + if n := countRows(t, p, `SELECT count(*) FROM scoped_tokens`); n != 2 { t.Fatalf("refused issues left rows behind: %d scoped tokens", n) } // A device with no stable id (legacy registration) cannot be scoped. diff --git a/internal/relay/scoped_store.go b/internal/relay/scoped_store.go index 8bfa6ea4..ba1b6a39 100644 --- a/internal/relay/scoped_store.go +++ b/internal/relay/scoped_store.go @@ -99,7 +99,7 @@ func (p *PGStore) IssueScopedToken(namespace, device, command, label string, day } var live int if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM tokens - WHERE namespace=$1 AND kind='scoped' AND revoked_at IS NULL AND expires_at>now()`, namespace).Scan(&live); err != nil { + WHERE namespace=$1 AND revoked_at IS NULL AND expires_at>now() AND EXISTS (SELECT 1 FROM scoped_tokens s WHERE s.token_id=tokens.id)`, namespace).Scan(&live); err != nil { return "", out, err } if live >= scopedMaxLive { @@ -108,7 +108,7 @@ func (p *PGStore) IssueScopedToken(namespace, device, command, label string, day raw := delegation.ScopedTokenPrefix + randHex(20) expires := time.Now().Add(time.Duration(days) * 24 * time.Hour) var tokenID int - if err = tx.QueryRowContext(ctx, `INSERT INTO tokens(namespace,kind,hash,label,expires_at) VALUES ($1,'scoped',$2,$3,$4) RETURNING id`, + if err = tx.QueryRowContext(ctx, `INSERT INTO tokens(namespace,kind,hash,label,expires_at) VALUES ($1,'delegated',$2,$3,$4) RETURNING id`, namespace, HashToken(raw), label, expires).Scan(&tokenID); err != nil { return "", out, err } @@ -124,7 +124,7 @@ func (p *PGStore) IssueScopedToken(namespace, device, command, label string, day func (p *PGStore) ListScopedTokens(namespace string) ([]ScopedToken, error) { rows, err := p.db.Query(`SELECT t.id,COALESCE(t.label,''),s.device_id,s.command,t.created_at,t.expires_at,t.revoked_at - FROM tokens t JOIN scoped_tokens s ON s.token_id=t.id WHERE t.namespace=$1 AND t.kind='scoped' ORDER BY t.id DESC`, namespace) + FROM tokens t JOIN scoped_tokens s ON s.token_id=t.id WHERE t.namespace=$1 ORDER BY t.id DESC`, namespace) if err != nil { return nil, err } @@ -148,7 +148,7 @@ func (p *PGStore) ListScopedTokens(namespace string) ([]ScopedToken, error) { // false when there is no such live scoped token, so a mistyped id is not // mistaken for a revocation. func (p *PGStore) RevokeScopedToken(namespace string, id int) (bool, error) { - res, err := p.db.Exec(`UPDATE tokens SET revoked_at=now() WHERE id=$1 AND namespace=$2 AND kind='scoped' AND revoked_at IS NULL`, id, namespace) + res, err := p.db.Exec(`UPDATE tokens SET revoked_at=now() WHERE id=$1 AND namespace=$2 AND revoked_at IS NULL AND EXISTS (SELECT 1 FROM scoped_tokens s WHERE s.token_id=tokens.id)`, id, namespace) if err != nil { return false, err } @@ -156,7 +156,12 @@ func (p *PGStore) RevokeScopedToken(namespace string, id int) (bool, error) { return n == 1, err } -// resolveScoped completes ResolveAccess for a token whose kind is "scoped". The +// resolveScoped completes ResolveAccess for a token that has a scoped_tokens +// row. Its tokens row is stored with kind 'delegated', not a kind of its own: +// every release before this one refuses that kind on its namespace-only lookup +// and cannot resolve it as a grant (no delegation request), so rolling the +// relay binary back leaves the token dead instead of turning it into a full +// account token. The // token's row has already been checked for revocation, expiry and a disabled // account; what is left is that the device it was issued for is still the same // device. From da81c12be54ef42647c092fddc21a81d60bde7fd Mon Sep 17 00:00:00 2001 From: e0_7 <134906805+Daily-AC@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:06:40 -0800 Subject: [PATCH 5/9] Pin JSON spelling behaviour of the scoped request check; clarify ADR Co-Authored-By: Claude Sonnet 5.5 --- docs/adr/0021-scoped-command-token.md | 7 +++-- internal/agent/scoped_test.go | 39 +++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/docs/adr/0021-scoped-command-token.md b/docs/adr/0021-scoped-command-token.md index d982a228..41a75403 100644 --- a/docs/adr/0021-scoped-command-token.md +++ b/docs/adr/0021-scoped-command-token.md @@ -89,8 +89,11 @@ places that each hold part of it: request to be *exactly* the canonical form of "exec this command": kind `exec`, `oneshot`, command equal byte for byte, and every other field zero except `spill_after` (output handling). It is an allow-list over the whole - message, not a deny-list of known-bad fields, so a field added to the - protocol later is rejected until someone decides otherwise. Everything else + decoded message, not a deny-list of known-bad fields, so a field added to + `protocol.Message` later is rejected until someone decides otherwise. The + wire decoder drops unknown keys and lets the last duplicate or any key + spelling win; that is sound because the decoded struct that is compared is + the struct that would run (pinned by `TestScopedRequestJSONSpelling…`). Everything else — file put/get/read/edit/write, logs, status, desktop, workspace, async exec, elevation, a different `cwd`, a different command, a pipe or `;` appended — gets a reject and is never executed. A changed client cannot get diff --git a/internal/agent/scoped_test.go b/internal/agent/scoped_test.go index 9b8d6f8f..3dac95fb 100644 --- a/internal/agent/scoped_test.go +++ b/internal/agent/scoped_test.go @@ -3,6 +3,7 @@ package agent import ( "bytes" "context" + "encoding/json" "errors" "io" "os" @@ -261,3 +262,41 @@ func TestScopedTokenBoundToWrongDeviceOrAgentIsRefused(t *testing.T) { t.Fatal("scoped token for another device reached this one") } } + +// Codex review finding: the wire decoder drops unknown keys and accepts +// duplicate or differently-cased keys, so the device compares the DECODED +// message. That is sound because the decoded message is also exactly what the +// device would execute; this test pins that: whatever spelling the bytes use, +// a request is accepted only if the struct that would run is the canonical one. +func TestScopedRequestJSONSpellingCannotSmuggleAnything(t *testing.T) { + cmd := "printf scoped-ok" + decode := func(raw string) protocol.Message { + t.Helper() + var m protocol.Message + if err := json.Unmarshal([]byte(raw), &m); err != nil { + t.Fatal(err) + } + return m + } + for name, raw := range map[string]string{ + "unknown key": `{"kind":"exec","command":"printf scoped-ok","oneshot":true,"future_field":"x"}`, + "case alias": `{"KIND":"exec","Command":"printf scoped-ok","OneShot":true}`, + "overridden cwd": `{"kind":"exec","command":"printf scoped-ok","oneshot":true,"cwd":"/","cwd":""}`, + "overridden extra": `{"kind":"exec","command":"id","oneshot":true,"command":"printf scoped-ok"}`, + } { + m := decode(raw) + if !scopedRequestAllowed(m, cmd) || m.Command != cmd || m.Cwd != "" || m.Elevate || m.Script != "" { + t.Fatalf("%s: decoded %+v", name, m) + } + } + for name, raw := range map[string]string{ + "duplicate command, last wins": `{"kind":"exec","command":"printf scoped-ok","oneshot":true,"command":"id"}`, + "duplicate cwd, last wins": `{"kind":"exec","command":"printf scoped-ok","oneshot":true,"cwd":"","cwd":"/"}`, + "case alias of a real field": `{"kind":"exec","command":"printf scoped-ok","oneshot":true,"CWD":"/"}`, + "escaped key": `{"kind":"exec","command":"printf scoped-ok","oneshot":true,"cwd":"/"}`, + } { + if m := decode(raw); scopedRequestAllowed(m, cmd) { + t.Fatalf("%s: accepted %+v", name, m) + } + } +} From b66dcc853d9ce0fe9cc05fba26df240289152ead Mon Sep 17 00:00:00 2001 From: e0_7 <134906805+Daily-AC@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:19:56 -0800 Subject: [PATCH 6/9] ADR 0021: accepted, ships with the next regular release Co-Authored-By: Claude Sonnet 5.5 --- docs/adr/0021-scoped-command-token.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0021-scoped-command-token.md b/docs/adr/0021-scoped-command-token.md index 41a75403..ae82c52f 100644 --- a/docs/adr/0021-scoped-command-token.md +++ b/docs/adr/0021-scoped-command-token.md @@ -1,7 +1,7 @@ # 0021 — Scoped command token: a credential that can run one command on one device Date: 2026-11-06 -Status: proposed (implementation on `feat/scoped-token`; not released) +Status: accepted (2026-11-06; ships with the next regular release) ## Context From 6aa2854277486c90a8b31f2a5ac1579b2a3acbf4 Mon Sep 17 00:00:00 2001 From: e0_7 <134906805+Daily-AC@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:20:32 -0800 Subject: [PATCH 7/9] ADR 0021: stay proposed until the release that ships it Co-Authored-By: Claude Sonnet 5.5 --- docs/adr/0021-scoped-command-token.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0021-scoped-command-token.md b/docs/adr/0021-scoped-command-token.md index ae82c52f..40ecc294 100644 --- a/docs/adr/0021-scoped-command-token.md +++ b/docs/adr/0021-scoped-command-token.md @@ -1,7 +1,7 @@ # 0021 — Scoped command token: a credential that can run one command on one device Date: 2026-11-06 -Status: accepted (2026-11-06; ships with the next regular release) +Status: proposed (merged with the next regular release; set to accepted when that release ships) ## Context From a8c66c8df9aad1c85679935b06a2d1e30bce57db Mon Sep 17 00:00:00 2001 From: e0_7 <134906805+Daily-AC@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:22:44 -0800 Subject: [PATCH 8/9] docs: scoped tokens need the next relay release Co-Authored-By: Claude Sonnet 5.5 --- docs/scoped-token.md | 4 ++++ docs/scoped-token.zh.md | 3 +++ 2 files changed, 7 insertions(+) diff --git a/docs/scoped-token.md b/docs/scoped-token.md index 810952d2..fab79e47 100644 --- a/docs/scoped-token.md +++ b/docs/scoped-token.md @@ -9,6 +9,10 @@ another command, the same command with a different argument, a pipe or `;` appended, another device, files, logs, a shell, your account, other tokens. It expires, and you can revoke it at any time. +> **Not available yet.** Scoped tokens need the next relay release (after +> v0.29.1), and devices updated to it. Until the relay you use is upgraded, +> `wanctl scoped-token` and the tokens it issues are refused. + ## Issue one With your normal login (`wanctl login`), on any machine: diff --git a/docs/scoped-token.zh.md b/docs/scoped-token.zh.md index 4c59796b..c7cc8a9d 100644 --- a/docs/scoped-token.zh.md +++ b/docs/scoped-token.zh.md @@ -6,6 +6,9 @@ 它只能在**一台设备上运行一条固定命令**。其余一律拒绝:换命令、同一条命令改参数、 末尾加管道或 `;`、换设备、文件、日志、shell、你的账号、其他令牌。它会过期,你随时可以撤销。 +> **暂时还不能用。** 限权令牌要等下一版 relay(v0.29.1 之后的版本)才支持,设备也要更新到 +> 那一版。在你用的 relay 升级之前,`wanctl scoped-token` 和它发出的令牌都会被拒绝。 + ## 发一把 用平常的登录(`wanctl login`),在任意一台机器上: From 56bb7bf42d38bac341ccf2abfbb9a23b06825463 Mon Sep 17 00:00:00 2001 From: e0_7 <134906805+Daily-AC@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:24:33 -0800 Subject: [PATCH 9/9] docs: keep the code blocks of the zh scoped-token page identical to the en page The docsite build requires code blocks to be copied, not translated. Co-Authored-By: Claude Sonnet 5.5 --- docs/scoped-token.zh.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/scoped-token.zh.md b/docs/scoped-token.zh.md index c7cc8a9d..78ab21a8 100644 --- a/docs/scoped-token.zh.md +++ b/docs/scoped-token.zh.md @@ -27,8 +27,8 @@ wanctl scoped-token create --device home-pc --command 'systemctl restart app' -- - 同时有效的限权令牌最多 50 把。 ```bash -wanctl scoped-token list # 编号、状态、命令、设备、备注 -wanctl scoped-token revoke 12 # 下一次请求就被拒 +wanctl scoped-token list # id, state, command, device, label +wanctl scoped-token revoke 12 # its next request is refused ``` ## 使用