diff --git a/docs/adr/0021-scoped-command-token.md b/docs/adr/0021-scoped-command-token.md
new file mode 100644
index 00000000..40ecc294
--- /dev/null
+++ b/docs/adr/0021-scoped-command-token.md
@@ -0,0 +1,166 @@
+# 0021 — Scoped command token: a credential that can run one command on one device
+
+Date: 2026-11-06
+Status: proposed (merged with the next regular release; set to accepted when that release ships)
+
+## 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, 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
+ 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 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
+
+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 `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
+ 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
+ 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
+ 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: 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/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..fab79e47
--- /dev/null
+++ b/docs/scoped-token.md
@@ -0,0 +1,83 @@
+# 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.
+
+> **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:
+
+```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'
+```
+
+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.
+
+## 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..78ab21a8
--- /dev/null
+++ b/docs/scoped-token.zh.md
@@ -0,0 +1,65 @@
+# 限权令牌(scoped token)
+
+限权令牌是给「只需要从设备上拿一件事」的程序用的凭据,比如「在 A 机上重启应用」
+「跑夜间备份」。令牌泄露或程序被诱导时,它也做不了别的。
+
+它只能在**一台设备上运行一条固定命令**。其余一律拒绝:换命令、同一条命令改参数、
+末尾加管道或 `;`、换设备、文件、日志、shell、你的账号、其他令牌。它会过期,你随时可以撤销。
+
+> **暂时还不能用。** 限权令牌要等下一版 relay(v0.29.1 之后的版本)才支持,设备也要更新到
+> 那一版。在你用的 relay 升级之前,`wanctl scoped-token` 和它发出的令牌都会被拒绝。
+
+## 发一把
+
+用平常的登录(`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 # id, state, command, device, label
+wanctl scoped-token revoke 12 # its next request is refused
+```
+
+## 使用
+
+把令牌交给程序,设为 `WANCTL_TOKEN`,加 `--oneshot` 运行这条命令:
+
+```bash
+WANCTL_TOKEN=wcs_… wanctl exec --target home-pc --oneshot -- 'systemctl restart app'
+```
+
+用令牌的那台机器和任何控制端一样,要先确认一次设备身份:
+`wanctl trust server --target home-pc --fingerprint SHA256:…`,指纹是设备上 agent 启动时打印的那一个。
+
+除了这一条请求,别的都会被 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..3dac95fb
--- /dev/null
+++ b/internal/agent/scoped_test.go
@@ -0,0 +1,302 @@
+package agent
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "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")
+ }
+}
+
+// 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)
+ }
+ }
+}
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/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..b7d9148d 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,27 @@ 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["kind"] = "scoped"
+ row["scoped_command"], row["scoped_device"] = scopedCommand, scopedDevice
+ }
+ out = append(out, row)
}
return out, rows.Err()
}
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..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,6 +281,9 @@ func (p *PGStore) ResolveAccess(raw string) (delegation.Access, bool) {
if expires.Valid {
out.ExpiresAt = expires.Time
}
+ if scoped {
+ return p.resolveScoped(ctx, id, out)
+ }
if kind != "delegated" {
return out, true
}
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..219f00c8
--- /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 '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.
+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/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..b466ee15
--- /dev/null
+++ b/internal/relay/scoped_pg_test.go
@@ -0,0 +1,275 @@
+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 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 {
+ 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")
+ }
+ // 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 {
+ 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 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)
+ }
+ // 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 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.
+ 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..ba1b6a39
--- /dev/null
+++ b/internal/relay/scoped_store.go
@@ -0,0 +1,181 @@
+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 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 {
+ 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,'delegated',$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 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 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
+ }
+ n, err := res.RowsAffected()
+ return n == 1, err
+}
+
+// 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.
+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",