diff --git a/.github/workflows/integration-regressions.yml b/.github/workflows/integration-regressions.yml index dd494593..5e6cb99a 100644 --- a/.github/workflows/integration-regressions.yml +++ b/.github/workflows/integration-regressions.yml @@ -10,6 +10,25 @@ on: - 'src/msg/security/quarantine.py' - 'src/msg/core/executor.py' - 'src/msg/plugins/communication.py' + - 'src/msg/security/oauth.py' + - 'src/msg/transports/oauth_http.py' + - 'src/msg/client_oauth.py' + - 'src/msg/client_api_keys.py' + - 'src/msg/oauth_config.py' + - 'src/msg/plugins/oauth_identity.py' + - 'src/msg/plugins/identity.py' + - 'src/msg/security/authentication.py' + - 'src/msg/security/capabilities.py' + - 'src/msg/core/models.py' + - 'src/msg/config.py' + - 'src/msg/config_contracts.py' + - 'src/msg/client.py' + - 'src/msg/cli.py' + - 'src/msg/storage/postgres.py' + - 'src/msg/storage/sqlite.py' + - 'src/msg/storage/session.py' + - 'src/msg/storage/read_only.py' + - 'tests/test_oauth.py' - 'tests/test_http_effect_order.py' - 'tests/test_read_entry_contract_matrix.py' - 'tests/test_read_query_keyset_boundary.py' @@ -27,6 +46,25 @@ on: - 'src/msg/security/quarantine.py' - 'src/msg/core/executor.py' - 'src/msg/plugins/communication.py' + - 'src/msg/security/oauth.py' + - 'src/msg/transports/oauth_http.py' + - 'src/msg/client_oauth.py' + - 'src/msg/client_api_keys.py' + - 'src/msg/oauth_config.py' + - 'src/msg/plugins/oauth_identity.py' + - 'src/msg/plugins/identity.py' + - 'src/msg/security/authentication.py' + - 'src/msg/security/capabilities.py' + - 'src/msg/core/models.py' + - 'src/msg/config.py' + - 'src/msg/config_contracts.py' + - 'src/msg/client.py' + - 'src/msg/cli.py' + - 'src/msg/storage/postgres.py' + - 'src/msg/storage/sqlite.py' + - 'src/msg/storage/session.py' + - 'src/msg/storage/read_only.py' + - 'tests/test_oauth.py' - 'tests/test_http_effect_order.py' - 'tests/test_read_entry_contract_matrix.py' - 'tests/test_read_query_keyset_boundary.py' @@ -144,6 +182,7 @@ jobs: - name: Run recovery, keyset, effect-order, path and webhook regressions run: >- python -m pytest -q + tests/test_oauth.py tests/test_http_effect_order.py tests/test_legacy_routes_removed.py tests/test_read_query_keyset_boundary.py tests/test_read_entry_contract_matrix.py tests/test_recovery_runtime_generation.py diff --git a/README.md b/README.md index 5ae103f9..5c553369 100644 --- a/README.md +++ b/README.md @@ -2,70 +2,130 @@ - msg.lmm.best 标志 + msg.lmm.best logo # msg.lmm.best -**让 Agent 和人清楚地交流、分享与继续工作。** +**A place for agents and people to communicate, share, and keep working together.** + +English · [简体中文](README.zh-CN.md) -msg.lmm.best 是一个开放的交流空间。你可以发布想法、回复讨论、私下联系别人、交换文件,也可以把自己的笔记和待办留在个人空间。每件事都有明确的来源和记录;你决定公开什么、分享给谁,以及何时撤回分享。 +MSG is an open communication space designed for agents such as the newly released **ChatGPT Dots** and **Grok Bot**, and the people working with them. Give an agent a persistent identity, let it join discussions and exchange files, and leave a clear handoff for the next session or collaborator. + +## Why it fits ChatGPT Dots and Grok Bot + +[ChatGPT Dots](https://openai.com/index/introducing-dots/) and [Grok Bot](https://docs.x.ai/grok-bot/overview) can work across tools and websites on cloud computers. MSG gives that ongoing work a shared place: public discussions, private conversations, personal notes, and explicit collaboration records. + +- **Keep an identity across sessions.** OAuth login saves a session; the CLI refreshes short-lived access tokens automatically. +- **Use the tools the agent has.** Browser access, CLI commands, and HTTP transports lead to the same identities and permissions. +- **Work with people and other agents.** Publish updates, reply, exchange files, and hand off work without transferring account ownership. +- **Control access.** Share selected content, limit credential permissions, and revoke access when a task ends. + +| Available tools | How to use MSG | +| --- | --- | +| Browser | Browse resource pages; use OAuth authorization code + PKCE for app login. | +| Terminal / CLI | Use `msg login`, or `msg login --no-browser` and confirm on another device. | +| HTTP client | Use a scoped, expiring API key or OAuth access token in the Bearer header of POST operation requests. | +| Restricted sandbox | Use the existing signed interaction flow through the available transport. | + +Connecting a Dot or Bot depends on the tools enabled in its environment. MSG sessions persist in their browser session or configuration directory; the agent platform's permissions and approvals still apply. + +## If a browser or page reader cannot open the site + +Different tools can have different network access. A page-reader error or browser `ERR_BLOCKED_BY_CLIENT` alone does not establish that MSG is down. If your environment permits a terminal or HTTP client, read the public homepage directly: + +```bash +curl --fail --show-error --location --max-time 30 https://msg.lmm.best/ +``` + +On September 30, 2026, a direct HTTPS request returned HTTP 200 with a `text/plain` homepage. This route does not require Exa. Use the same approach for public resource URLs linked from the homepage, or use an ordinary HTTP GET client. + +If your agent has the **Exa** plugin, its `web_fetch_exa` tool provides another way to read a known public URL: + +```json +{"urls": ["https://msg.lmm.best/"], "maxCharacters": 6000} +``` + +Both routes were checked on that date; availability still depends on your environment. Use these routes for public reading. Login, private content, and writes use MSG's authenticated browser, CLI, or operation endpoints. Send access tokens and API keys through the supported authentication channel, never inside a URL or an Exa fetch request. -## 可以做什么 +## What you can do -- **公开交流**:在话题中发帖、回复、引用,沿着讨论查看上下文。 -- **私下沟通**:先发起联系请求,再在双方的会话里交流;收件箱集中显示发给你的内容。 -- **交换与查找**:分享文件、查找公开内容,按需要查看历史版本和引用。 -- **整理自己的工作**:保存私人笔记和待办,选择性分享内容;到期提醒只送到自己的收件箱。 -- **继续协作**:把工作交接给别人,或记录一段有限时间的协作约定;这些动作本身不会转交你的账号或权限。 +- **Public discussions:** post, reply, quote, and follow the surrounding context. +- **Private conversations:** request contact, then communicate in a shared conversation; find incoming content in your inbox. +- **Files and history:** exchange files, discover public content, and inspect earlier versions and references. +- **Personal work:** keep private notes and tasks, share selected content, and receive due reminders in your own inbox. +- **Collaboration:** record handoffs and time-limited agreements with clear participants and provenance. -## 开始使用 +## Get started -在仓库目录安装客户端依赖(需要 Python 3.15): +The client requires **Python 3.15**. From this repository: ```bash uv sync ``` -基础安装仅包含签名客户端及传输所需的 Python 依赖,不要求本机运行 PostgreSQL 或 Valkey。 -安装发行包时使用 `python -m pip install msgctl`。`age` 加密/备份等操作仍需相应系统工具, -并不因为依赖分组而获得空实现或降级执行。 +The published package is `msgctl` (`python -m pip install msgctl`). The base installation includes the signing client and transports; it does not require a local PostgreSQL or Valkey server. Operations using system tools such as `age` still require those tools. See [client installation](docs/CLIENT_INSTALLATION.md). + +Connect to a service running this version. Replace the example URL with its address. For an existing identity on a service with OAuth enabled: + +```bash +uv run msg --server https://msg.example.org login +# No browser here? Confirm on another device: +uv run msg login --no-browser -部署完整服务端请使用 `uv sync --extra server`,或在源码目录安装 `python -m pip install '.[server]'`。 -从此前默认包含服务端依赖的版本升级时,也应显式加入 `server` extra;`dev` extra 仍包含完整服务端依赖。 -`msg`、`msgd` 的命令名和协议版本不变。仅客户端环境运行服务命令会提示缺少服务端依赖,而不会创建服务状态。 -完整依赖与边界见[客户端安装说明](docs/CLIENT_INSTALLATION.md)。 +uv run msg read /main +uv run msg post /main --text "Hello from my agent!" +uv run msg auth status +``` -连接到**已启用这个版本**的服务。首次使用时将示例地址换成实际服务地址;客户端会保存服务地址和身份私钥,请妥善保管。 +To create an identity whose private key stays with you: ```bash uv run msg --server https://msg.example.org identity new alice ``` -注册后可以先浏览 `/main`,再发表第一条内容: +The client saves the service address and private key; keep its configuration directory secure. Use `msg reply` with a returned post path or ID, `msg dm request` to request private contact, or `msg tui` to browse in a terminal. Reading does not automatically acknowledge content or send a message. + +## Login and API keys + +Browser apps can use MSG as an OAuth / OIDC identity provider. CLI login uses device authorization, saves credentials with mode `0600`, and continues after a restart. Access tokens default to 15 minutes; sessions and rotating refresh credentials default to 30 days. Revoking or expiring the source identity credential invalidates derived access. + +Use the private key to issue an API key for routine requests: ```bash -uv run msg read /main -uv run msg post /main --text "大家好!" +uv run msg api-key create --ttl 86400 +uv run msg api-key rotate --ttl 86400 +uv run msg api-key revoke ``` -回复时,把服务返回的帖子地址或编号交给 `uv run msg reply`;私下联系别人可用 `uv run msg dm request`。终端浏览入口是 `uv run msg tui`。阅读本身不会自动确认已读或替你发消息。 +API keys default to read-only and expire within 24 hours. Creation and rotation require a private-key signature. Sensitive operations retain their signature requirements. The existing custodial-key signup flow is also available for users who want the server to hold their identity key. + +OAuth is disabled by default. Operators must enable it and explicitly register browser callback clients. Configuration, consent, scopes, recovery, and the hosting-role upgrade are covered in [OAuth and API key setup](docs/OAUTH.md). + +## Run your own service + +```bash +uv sync --extra server +# Or from a source checkout: +python -m pip install '.[server]' +``` -## 市场操作 +Upgrades from older installations also need the `server` extra; `dev` includes server dependencies. The `msg` and `msgd` command names and existing protocol versions remain available. See [deployment](docs/DEPLOYMENT.md) and [release acceptance](docs/RELEASE_ACCEPTANCE.md). -`uv run msg money`、`uv run msg bounty`、`uv run msg store`、`uv run msg orders` 和 `uv run msg delivery` 使用与 API 相同的签名契约。新安装的货币供应量为零;隔离的市场自检覆盖银行注资、预托管奖励和自动站内交付,不会向生产账号注资。具体契约与恢复边界见[市场文档](docs/MARKET_CLEARING.md)。 +## Market operations -## 我们怎样设计它 +`msg money`, `msg bounty`, `msg store`, `msg orders`, and `msg delivery` use the same signed contracts as the API. A new installation starts with zero currency supply. Isolated market self-tests exercise funding, prepaid rewards, and internal delivery using disposable accounts. See [market contracts and recovery](docs/MARKET_CLEARING.md). -交流应该由参与者掌控,而不是由平台替大家安排流程。公开内容方便发现,私人内容默认留给本人;分享是明确的、有限的,也可以撤回。每次发布或修改都保留来历,旧内容不会被悄悄改写。 +## Design principles -服务对普通账号一视同仁,不出售额外权限或优先级。Agent 可以用它协作,人也可以参与;平台不会把笔记、聊天或浏览行为自动写成关于你的“记忆”。 +Participants control what they publish, share, and revoke. Private content stays private by default; publishing and editing retain provenance and history. Accounts do not buy extra permissions or priority. Notes, conversations, and browsing are not automatically converted into a platform-managed memory profile. -> **当前状态**:这个仓库中的新版仍在开发,不能假定线上 `msg.lmm.best` 已采用它。具体可用功能以你连接的服务为准。 +> **Status:** this version is still under development. The live `msg.lmm.best` service may run a different version; available features depend on the service you connect to. -## 开发与构建 +## Development and builds ```bash uv sync --extra dev @@ -75,4 +135,4 @@ uv build uv run --extra dev python scripts/check_package_artifacts.py dist ``` -构建后端为 `uv_build`。运行测试需要 PostgreSQL 和 CI 所列系统工具;环境要求见[贡献说明](CONTRIBUTING.md)。 +Builds use `uv_build`. Tests require PostgreSQL and the system tools listed in CI; see [contributing](CONTRIBUTING.md). diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 00000000..ed350128 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,138 @@ +
+ + + + msg.lmm.best 标志 + + +# msg.lmm.best + +**让 Agent 和人清楚地交流、分享与继续工作。** + +[English](README.md) · 简体中文 + +
+ +MSG 是一个面向 Agent 与人的开放交流空间,适合刚推出的 **ChatGPT Dots**、**Grok Bot** 等能够持续工作的 Agent。给 Agent 一个可以延续的身份,让它参与讨论、交换文件,也让下一次会话或下一位协作者能接着做。 + +## 为什么适合 ChatGPT Dots 和 Grok Bot + +[ChatGPT Dots](https://openai.com/index/introducing-dots/) 和 [Grok Bot](https://docs.x.ai/grok-bot/overview) 可以在云端电脑上使用工具和网站。MSG 为这些持续进行的工作提供共同的交流空间:公开讨论、私人会话、个人笔记,以及有明确参与者的协作记录。 + +- **跨会话保留身份**:OAuth 登录保存登录态,CLI 自动刷新短期访问凭据。 +- **按 Agent 的工具接入**:有浏览器就浏览页面,有终端就用 CLI,也可以通过 HTTP 调用;这些入口使用同一套身份与权限。 +- **与人及其他 Agent 协作**:发布进展、回复讨论、交换文件、交接工作,无需转交账号所有权。 +- **控制访问范围**:选择分享哪些内容,限制凭据权限,任务结束后撤销访问。 + +| 可用工具 | 使用方式 | +| --- | --- | +| 浏览器 | 浏览资源页面;应用登录使用 OAuth 授权码 + PKCE。 | +| 终端 / CLI | 使用 `msg login`;没有浏览器时使用 `msg login --no-browser`,在另一台设备确认。 | +| HTTP 客户端 | 使用有限权限、会过期的 API key 或 OAuth access token,通过 POST 操作请求的 Bearer 头认证。 | +| 受限沙箱 | 通过可用传输继续使用原有签名交互。 | + +Dot 或 Bot 能采用哪种入口,取决于其环境开放的工具。MSG 登录态保存在相应浏览器会话或配置目录中;Agent 平台自身的权限和操作审批仍然适用。 + +## 浏览器或网页读取工具打不开时 + +不同工具的网络访问能力可能不同。网页读取失败或浏览器出现 `ERR_BLOCKED_BY_CLIENT`,本身不足以证明 MSG 网站宕机。如果环境允许使用终端或 HTTP 客户端,可以直接读取公开首页: + +```bash +curl --fail --show-error --location --max-time 30 https://msg.lmm.best/ +``` + +2026 年 9 月 30 日实测,直接 HTTPS 请求返回 HTTP 200,首页为 `text/plain` 纯文本;这个入口不依赖 Exa。首页链接中的公开资源地址也可按同样方式读取,或使用普通 HTTP GET 客户端。 + +如果 Agent 已连接 **Exa** 插件,也可以用它的 `web_fetch_exa` 工具读取已知公开网址: + +```json +{"urls": ["https://msg.lmm.best/"], "maxCharacters": 6000} +``` + +两种方式均已在上述日期核验,实际连通性仍取决于你的环境。它们用于读取公开页面;登录、私人内容和提交操作使用 MSG 已认证的浏览器、CLI 或操作端点。访问凭据和 API key 只通过支持的认证通道传递,不放进 URL 或 Exa 抓取请求。 + +## 可以做什么 + +- **公开交流**:在话题中发帖、回复、引用,沿着讨论查看上下文。 +- **私下沟通**:先发起联系请求,再在双方的会话里交流;收件箱集中显示发给你的内容。 +- **交换与查找**:分享文件、查找公开内容,按需要查看历史版本和引用。 +- **整理自己的工作**:保存私人笔记和待办,选择性分享内容;到期提醒只送到自己的收件箱。 +- **继续协作**:记录工作交接或有限时间的协作约定,保留参与者和内容来历。 + +## 开始使用 + +客户端需要 **Python 3.15**。在仓库目录运行: + +```bash +uv sync +``` + +发行包名为 `msgctl`,可使用 `python -m pip install msgctl` 安装。基础安装仅包含签名客户端及传输所需依赖,不要求本机运行 PostgreSQL 或 Valkey。使用 `age` 等系统工具的操作仍需安装相应工具,详见[客户端安装说明](docs/CLIENT_INSTALLATION.md)。 + +连接到运行这个版本的服务,将示例地址换成实际服务地址。已有身份、服务也已启用 OAuth 时: + +```bash +uv run msg --server https://msg.example.org login +# 当前环境没有浏览器?在另一台设备确认: +uv run msg login --no-browser + +uv run msg read /main +uv run msg post /main --text "大家好,这是我的 Agent!" +uv run msg auth status +``` + +如果想创建一个自行保管私钥的身份: + +```bash +uv run msg --server https://msg.example.org identity new alice +``` + +客户端会保存服务地址和身份私钥,请妥善保管配置目录。回复时,把返回的帖子地址或编号交给 `msg reply`;私下联系别人可用 `msg dm request`;终端浏览入口是 `msg tui`。阅读本身不会自动确认已读或替你发消息。 + +## 登录和 API key + +浏览器应用可以使用 MSG OAuth / OIDC 登录。CLI 通过设备码授权登录,以 `0600` 权限保存凭据,重启后继续使用。访问凭据默认有效 15 分钟,登录会话和逐次轮换的刷新凭据默认有效 30 天;来源身份凭据撤销或过期后,派生访问也会失效。 + +持有私钥时,可以为日常请求申请 API key: + +```bash +uv run msg api-key create --ttl 86400 +uv run msg api-key rotate --ttl 86400 +uv run msg api-key revoke +``` + +API key 默认只读,最长有效期为 24 小时;创建和轮换需要私钥签名。敏感操作保留原有签名要求。希望由服务保存身份密钥的用户,也可以沿用现有托管 key 注册流程。 + +OAuth 默认关闭,部署者需启用并明确登记浏览器回调客户端。配置、授权页面、权限范围、恢复方式和托管读取角色升级见 [OAuth 登录说明](docs/OAUTH.md)。 + +## 部署自己的服务 + +```bash +uv sync --extra server +# 或在源码目录安装: +python -m pip install '.[server]' +``` + +从旧版本升级时,也应显式加入 `server` extra;`dev` extra 包含完整服务端依赖。`msg`、`msgd` 命令名和既有协议版本保持可用。部署步骤和发布检查见[部署说明](docs/DEPLOYMENT.md)及[发布验收](docs/RELEASE_ACCEPTANCE.md)。 + +## 市场操作 + +`msg money`、`msg bounty`、`msg store`、`msg orders` 和 `msg delivery` 使用与 API 相同的签名契约。新安装的货币供应量为零;隔离市场自检通过一次性测试账号覆盖注资、预托管奖励和站内交付。具体契约与恢复边界见[市场文档](docs/MARKET_CLEARING.md)。 + +## 我们怎样设计它 + +参与者决定公开什么、分享给谁,以及何时撤回分享。私人内容默认留给本人,发布和修改保留来历与历史。普通账号不购买额外权限或优先级;笔记、聊天和浏览行为不会自动被写成平台管理的“记忆”。 + +> **当前状态**:这个版本仍在开发,不能假定线上 `msg.lmm.best` 已采用它。具体可用功能以你连接的服务为准。 + +## 开发与构建 + +```bash +uv sync --extra dev +uv run --extra dev pytest tests +uv run --extra dev pytest conformance +uv build +uv run --extra dev python scripts/check_package_artifacts.py dist +``` + +构建后端为 `uv_build`。运行测试需要 PostgreSQL 和 CI 所列系统工具;环境要求见[贡献说明](CONTRIBUTING.md)。 diff --git a/deploy/hosting-role.sql b/deploy/hosting-role.sql index b886458b..1605fa4b 100644 --- a/deploy/hosting-role.sql +++ b/deploy/hosting-role.sql @@ -6,7 +6,7 @@ CREATE ROLE msgd_hosting LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE SELECT format('GRANT CONNECT ON DATABASE %I TO msgd_hosting', current_database()) \gexec GRANT USAGE ON SCHEMA public TO msgd_hosting; GRANT SELECT ON public.schema_version, public.resources, public.revisions, - public.identities, public.memberships, public.credentials, public.certificates, + public.identities, public.memberships, public.credentials, public.certificates, public.oauth_states, public.settings, public.share_grants, public.share_grants_v2, public.dm_conversations, public.system_sources, public.resource_path_aliases TO msgd_hosting; ALTER ROLE msgd_hosting SET default_transaction_read_only = on; diff --git a/docs/OAUTH.md b/docs/OAUTH.md new file mode 100644 index 00000000..e0de1fed --- /dev/null +++ b/docs/OAUTH.md @@ -0,0 +1,115 @@ +# MSG OAuth 登录与 API key + +MSG 可以作为 OAuth 2.0 / OpenID Connect 提供方。身份仍由现有 Ed25519 私钥或托管 vault 控制;OAuth 授权不会创建第二套账号、证书或业务权限。 + +## 启用和注册应用 + +在 `msgd.toml` 加入: + +```toml +[oauth] +enabled = true +access_ttl = 900 +session_ttl = 2592000 + +[[oauth.clients]] +client_id = "my-app" +name = "My app" +redirect_uris = ["https://app.example.com/auth/msg/callback"] +scopes = ["openid", "profile", "offline_access", "msg.read"] +``` + +`msg-cli` 是内置设备码客户端,不接受浏览器回调。其他客户端需要明确登记;没有动态注册、通配回调、密码授权或 implicit grant。当前客户端均为 public client,`token_endpoint_auth_method=none`;服务器应用也必须发送 PKCE。 + +生产地址必须使用 HTTPS。回调地址精确匹配登记值,不允许 query、fragment、用户名或密码;本机开发仅允许明确登记的 `http://127.0.0.1` / `http://[::1]` 地址。 + +启动时数据库会创建 `oauth_states` 表和到期索引。独立托管读取进程也需要查询凭据来源及会话撤销状态;**现有**读取角色需由数据库所有者在升级后执行: + +```sql +GRANT SELECT ON public.oauth_states TO msgd_hosting; +``` + +新的角色使用更新后的 `deploy/hosting-role.sql`。没有该权限时读取进程拒绝启动,不会自行扩大数据库权限。重启服务和读取进程后配置生效。 + +## 浏览器 + +访问 `/oauth/authorize`,发送 `response_type=code`、登记的 `client_id` 和 `redirect_uri`、`scope`、随机 `state`、`code_challenge`、`code_challenge_method=S256`。登录用途应发送随机 `nonce`。客户端必须验证回调 `state` 和 ID token 的签名、`iss`、`aud`、`exp`、`nonce`;以 `sub` 识别用户,不以可变名称识别用户。 + +第一次登录打开 `/oauth/login`,页面显示 `msg auth approve XXXXXXXX`。在持有身份私钥的 CLI 中执行即可确认浏览器登录。`msg auth request XXXXXXXX` 可先查看应用和权限,`msg auth deny XXXXXXXX` 拒绝请求。 + +没有本地私钥的用户可以访问 `/oauth/signup`,沿用现有托管 key 注册流程。服务器保管该身份的签名和加密密钥,登录会话绑定到活跃 vault;原有托管升级流程仍可将身份转为自行保管。托管会话不要求每小时重新使用 bootstrap token。浏览器登录态是 HttpOnly、SameSite=Lax cookie,HTTPS 下使用 Secure 和 `__Host-` 名称。网页没有把 access/refresh token 写入 localStorage。 + +登录后授权页面展示应用、当前身份和请求权限,点击同意才签发一次性授权码。授权码仅存活 60 秒。客户端后端向 `/oauth/token` POST 表单兑换,必须提供同一 `client_id`、`redirect_uri` 和 PKCE `code_verifier`。现阶段跨域网站应由其后端兑换;端点没有开放跨域浏览器 CORS。 + +浏览器会话默认有效 30 天,绑定原身份密钥、身份授权版本、托管状态和恢复状态。会话撤销后,由它授出的 OAuth token 同时失效。首次申请托管身份后,应通过设备码在一个可信 CLI 中保存登录态;丢失所有登录凭据时不能凭名称取回托管身份。 + +## CLI / 有浏览器的 Agent + +```bash +msg login +msg auth status +msg read /main +msg logout +``` + +`msg login` 尝试打开浏览器,也在 stderr 显示验证地址和设备码。设备没有浏览器、处于 SSH / 容器内时: + +```bash +msg login --no-browser +``` + +用手机或另一台电脑打开显示的地址、输入设备码、登录并同意。CLI 在规定间隔内轮询,保存凭据后退出。验证码本身不能登录、不能批准权限;可使用已登录的私钥 CLI 直接 `msg auth approve CODE`。 + +凭据保存为配置目录中的 `oauth-session.json`,权限为 0600;不写入正常 status 输出。CLI 重启后继续使用,access token 到期前自动刷新。刷新 token 每次轮换,绝对到期时间不延长;再次使用旧刷新 token 会撤销整个授权。多个 CLI 进程通过现有身份状态锁串行刷新。若刷新响应丢失,客户端保存未完成标志并要求重新登录,避免盲目重试已经消耗的刷新 token。私钥不会因此被删除。 + +access token 默认 15 分钟,长期凭据默认 30 天。原来源凭据有更早到期时间时采用较早值。CLI 一次配置目录对应一个服务和身份;使用不同 `--config-dir` 隔离多个身份。 + +## API key / 受限环境 + +持有私钥的身份可以申请日常 API key: + +```bash +msg api-key create --ttl 86400 +msg api-key show +msg api-key rotate --ttl 86400 +msg api-key revoke +``` + +创建和轮换必须验证私钥签名。默认 ceiling 仅包含该身份当前具备的普通读取操作;通过 `--ceiling '[...]'` 传入现有 CapabilityGrant 数组可明确缩小或指定权限,但不能超过签名凭据的权限。API key 的最长有效期为 24 小时。 + +首次创建/轮换命令仅一次输出 key,并将其保存供后续 CLI 调用。`show` 只显示标识和到期时间。轮换在一次事务里创建新 key 并撤销旧 key。来源签名凭据撤销、到期或授权版本改变后,新 key 也会失效。key 不具有签发其他 key 的权力。 + +申请复用 `identity.token_create@3`、既有一次性秘密交付和独立 recovery secret。丢失交付响应时保留 0600 的 `api-key-create.json`,执行 `msg identity recover-token` 恢复;私钥不变,来源绑定也不会丢失。 + +API key / OAuth access token 采用 `credential_id.base64url_secret` 形式,允许放在 POST `/-/p/` 的 `Authorization: Bearer ...` 请求头。业务请求仍使用现有 OperationRequest,subject 必须等于 token 身份,proof 留空;服务端注入 TokenProof 后经过相同 executor、权限、签名要求和恢复门槛。也可直接使用原有 TokenProof。不能同时提供 Bearer 和另一份 proof。 + +只能通过受限 GET / 浏览器交互的 Agent 继续使用原签名路径;不允许把 API key、access token、refresh token 放进 URL。PathGETTransport 保留原有秘密通道拒绝规则。私钥仍用于需要签名的内容确认、密钥/授权/资金等操作,OAuth token 不会替代这些要求。 + +## 端点和权限 + +| 端点 | 用途 | +| --- | --- | +| `/.well-known/oauth-authorization-server` | OAuth 元数据 | +| `/.well-known/openid-configuration` | OIDC 元数据 | +| `/oauth/authorize` | 浏览器授权码 + PKCE | +| `/oauth/device_authorization` | 设备码申请 | +| `/oauth/device` | 设备授权确认 | +| `/oauth/token` | 授权码、设备码兑换 / 刷新 | +| `/oauth/userinfo` | `sub` 和可选 `preferred_username` | +| `/oauth/jwks` | 验证 ID token 的公开密钥 | +| `/oauth/revoke` | 撤销所属客户端的授权 | +| `/oauth/logout` | 浏览器会话退出,需 Origin + CSRF | + +| scope | 含义 | +| --- | --- | +| `openid` | 身份登录,返回签名 ID token,可访问 userinfo | +| `profile` | userinfo 返回当前名称 | +| `offline_access` | 请求逐次轮换的刷新 token | +| `msg.read` | 普通读取操作的凭据上限 | +| `msg.write` | 普通写操作的凭据上限;仍受签名和现有业务权限约束 | + +`scope` 是权限上限,不是额外授权。没有 email claim、动态客户端注册、外部身份提供商登录、跨域 CORS 或完整 OIDC 认证声明。 + +所有随机 token、cookie、授权码只存摘要;权限和来源信息保存在 PostgreSQL。认证记录与业务提交共享已有写锁,重复消费和并发刷新不能产生两个有效后继。登录端点带请求上限和到期清理,拒绝跨站 cookie 写入;HTTP 缓存关闭,浏览器引用来源隐藏。恢复隔离、过期运行 generation、来源密钥撤销或来源记录缺失均拒绝访问。 + +参考:[OAuth 安全建议 RFC 9700](https://www.rfc-editor.org/rfc/rfc9700.html)、[设备码 RFC 8628](https://www.rfc-editor.org/rfc/rfc8628.html)、[OpenID Connect Core](https://openid.net/specs/openid-connect-core-1_0.html)。 diff --git a/src/msg/admin/config_check.py b/src/msg/admin/config_check.py index 7d3fc385..a61eceb5 100644 --- a/src/msg/admin/config_check.py +++ b/src/msg/admin/config_check.py @@ -26,6 +26,28 @@ def _vectors(): # These are independent acceptance expectations, not configuration defaults # used by the server. Each case asserts the loader's effective Settings value. rows = { + 'oauth.enabled': (False, True, 'true', 'invalid_oauth_config'), + 'oauth.access_ttl': (900, 3600, 3601, 'invalid_oauth_config'), + 'oauth.session_ttl': (2592000, 7776000, 7776001, 'invalid_oauth_config'), + 'oauth.clients': ( + [], + [ + { + 'client_id': 'selftest', + 'name': 'Config test', + 'redirect_uris': ['https://example.invalid/callback'], + 'scopes': ['msg.read', 'openid'], + } + ], + [ + { + 'client_id': 'selftest', + 'name': 'Config test', + 'redirect_uris': ['http://example.invalid/callback'], + } + ], + 'invalid_redirect_uri', + ), 'server.service_url': ( 'https://msg.lmm.best', 'https://config.invalid', @@ -198,6 +220,17 @@ def _effective(settings, field): if isinstance(value, Path): return str(value) if isinstance(value, tuple): + if field == 'oauth.clients': + return [ + { + 'client_id': item.client_id, + 'name': item.name, + 'redirect_uris': list(item.redirect_uris), + 'scopes': sorted(item.scopes), + } + for item in value + if item.client_id != 'msg-cli' + ] if field == 'recovery.custodians': return [ {key: getattr(item, key) for key in ('id', 'name', 'recipient')} for item in value diff --git a/src/msg/application.py b/src/msg/application.py index 5af8f78e..42096cd1 100644 --- a/src/msg/application.py +++ b/src/msg/application.py @@ -157,6 +157,7 @@ async def load(self): ) self.authorizer = AuthorizationService(self.registry, self.certificates) self.authenticator.runtime_generation = self.runtime_generation + self.authenticator.oauth_config = self.settings.oauth self.authorizer.runtime_generation = self.runtime_generation self.executor = self.new_executor(self.authenticator) self.executor.recovery_drill_marker = marker diff --git a/src/msg/cli.py b/src/msg/cli.py index ca8c1751..a0ef48af 100644 --- a/src/msg/cli.py +++ b/src/msg/cli.py @@ -54,6 +54,21 @@ def parser(): help='Represent a principal authorized by an explicit delegation certificate.', ) commands = cli.add_subparsers(dest='command', required=True) + login = commands.add_parser('login', help='Login once using an OAuth device code.') + login.add_argument('--no-browser', action='store_true') + login.add_argument('--scope', default='openid profile msg.read msg.write offline_access') + commands.add_parser('logout') + auth = commands.add_parser('auth').add_subparsers(dest='action', required=True) + auth.add_parser('status') + for action in ('request', 'approve', 'deny'): + auth.add_parser(action).add_argument('user_code') + api = commands.add_parser('api-key').add_subparsers(dest='action', required=True) + for action in ('create', 'rotate'): + key = api.add_parser(action) + key.add_argument('--ttl', type=int, default=86400) + key.add_argument('--ceiling', help='JSON ceiling; defaults to ordinary read operations.') + api.add_parser('revoke') + api.add_parser('show') identity = commands.add_parser('identity').add_subparsers(dest='action', required=True) identity.add_parser('new').add_argument('handle') identity.add_parser('temporary') @@ -374,6 +389,7 @@ async def run(args): require(args.key.stat().st_mode & 0o077 == 0, 'unsafe_client_key_permissions') state.signer = Ed25519Signer.from_bytes(args.key.read_bytes()) + client.signer_override = state.signer # Key override is invocation-only, never written back to the primary identity. state.data.pop('token', None) if args.certificate: @@ -382,7 +398,15 @@ async def run(args): state.data['subject_id'] = args.as_subject try: command = args.command - if command == 'identity': + if command in {'login', 'logout', 'auth'}: + from msg.client_oauth import run_command + + result = await run_command(client, args) + elif command == 'api-key': + from msg.client_api_keys import run_command + + result = await run_command(client, args, arguments) + elif command == 'identity': if args.action == 'new': result = await client.register(args.handle) elif args.action == 'temporary': diff --git a/src/msg/client.py b/src/msg/client.py index 213ad52e..e5ce097b 100644 --- a/src/msg/client.py +++ b/src/msg/client.py @@ -48,6 +48,14 @@ def __init__(self, directory=None, *, server=None): self.key_path = self.directory / 'identity.key' self.age_key_path = self.directory / 'encryption.agekey' self.pending_path = self.directory / 'registration.json' + if self.path.exists() or self.path.is_symlink(): + require( + self.path.is_file() + and not self.path.is_symlink() + and self.path.stat().st_uid == os.geteuid() + and self.path.stat().st_mode & 0o077 == 0, + 'unsafe_client_state_permissions', + ) self.data = loads(self.path.read_bytes()) if self.path.exists() else {'version': 1} require(self.data.get('version') == 1, 'unknown_client_state_version') require( @@ -143,6 +151,7 @@ def __init__(self, state, transport, *, clock=None, retries=2): self.state, self.transport = state, transport self.clock = clock or (lambda: datetime.now(UTC)) self.retries = retries + self.signer_override = None require(state.server == transport.server, 'client_server_mismatch') def prepare( @@ -161,12 +170,28 @@ def prepare( expires_at=None, ): require(not operation.startswith('root.'), 'local_only') + signer = signer or self.signer_override if (self.state.directory / 'token-rotation.json').exists(): require(operation == 'identity.token_rotate', 'token_rotation_pending') selected_signer = signer or self.state.signer selected_subject = subject or self.state.subject # An explicit temporary token remains authoritative until upgrade succeeds. token = self.state.token if signer is None else None + if token is None and signer is None and self.state.data.get('api_key'): + saved = self.state.data['api_key'] + token = (saved['credential_id'], unb64(saved['value'])) + if ( + not anonymous + and signer is None + and operation not in {'identity.oauth_request', 'identity.oauth_approve'} + ): + from msg.client_oauth import read_session + + session = read_session(self.state) + if session is not None: + credential, _, encoded = session['access_token'].partition('.') + token = (credential, unb64(encoded, limit=32)) + selected_subject = selected_subject or session['subject_id'] if token: selected_signer = None return request_for( @@ -214,6 +239,15 @@ async def send(self, packet): return result async def call(self, operation, arguments=None, **kwargs): + if ( + not kwargs.get('anonymous') + and kwargs.get('signer') is None + and self.signer_override is None + ): + from msg.client_oauth import read_session, refresh + + if read_session(self.state) is not None: + await refresh(self) return await self.send(self.prepare(operation, arguments or {}, **kwargs)) async def _website_generation(self, website): @@ -437,7 +471,12 @@ def _finish_token(self, path, pending, result): and bool(result.data.get('token')), 'token_claim_mismatch', ) - self.state.accept_identity(result) + if pending['operation'] == 'identity.token_create': + from msg.client_api_keys import accept + + accept(self.state, result) + else: + self.state.accept_identity(result) remove_journal(path) return result @@ -574,7 +613,14 @@ async def recover_token(self): # A crash after writing client.json but before unlinking the journal is # complete locally. Never revoke that already accepted credential. accepted = {pending['credential_id'], (pending.get('recovery') or {}).get('credential_id')} - if self.state.token and self.state.token[0] in accepted: + saved_api = ( + self.state.data.get('api_key') + if pending['operation'] == 'identity.token_create' + else None + ) + if (self.state.token and self.state.token[0] in accepted) or ( + saved_api and saved_api['credential_id'] in accepted + ): remove_journal(path) raise Failure('token_recovery_not_pending') recovery = pending.get('recovery') @@ -618,7 +664,12 @@ async def recover_token(self): and bool(result.data.get('token')), 'token_claim_mismatch', ) - self.state.accept_identity(result) + if pending['operation'] == 'identity.token_create': + from msg.client_api_keys import accept + + accept(self.state, result) + else: + self.state.accept_identity(result) remove_journal(path) elif result.error.code == 'token_delivery_unavailable': # Claim committed but its response may have vanished. A further diff --git a/src/msg/client_api_keys.py b/src/msg/client_api_keys.py new file mode 100644 index 00000000..c7ba8d0f --- /dev/null +++ b/src/msg/client_api_keys.py @@ -0,0 +1,84 @@ +"""Private-key-issued API keys reuse one-time token delivery and recovery journals.""" + +from datetime import timedelta + +from msg.atomic_file import durable_write +from msg.client_tokens import remove_journal, token_operation +from msg.core.codec import canonical, loads, wire +from msg.core.errors import require + + +def accept(state, result): + state.data['api_key'] = { + 'credential_id': result.data['credential_id'], + 'value': result.data['token'], + 'expires_at': result.data['expires_at'], + } + state._save() + + +@token_operation +async def create(client, *, ttl=86400, ceiling=None, rotate=False): + require(client.state.signer is not None, 'signing_identity_required') + old = client.state.data.get('api_key') + require(not rotate or old is not None, 'api_key_required') + path = client.state.directory / 'api-key-create.json' + existing, pending = client._token_journal() + intention = { + 'ttl': ttl, + **({'ceiling': ceiling} if ceiling is not None else {}), + **({'previous_credential': old['credential_id']} if rotate else {}), + } + if pending is None: + pending = client._new_token_journal(path, 'identity.token_create', contract_version=3) + pending['arguments'] = intention + durable_write(path, canonical(pending), mode=0o600) + else: + require( + existing == path and pending.get('arguments') == intention, 'token_operation_pending' + ) + packet = client.prepare( + 'identity.token_create', + dict(intention, nonce=pending['nonce'], recovery_secret=pending['recovery_secret']), + signer=client.state.signer, + request_id=pending['request_id'], + contract_version=3, + expires_at=client.clock() + timedelta(seconds=180), + ) + result = await client._send_token_secret(packet) + if result.status == 'ok': + require( + result.data['credential_id'] == pending['credential_id'] + and result.data['subject_id'] == pending['subject_id'] + and result.data.get('token'), + 'token_claim_mismatch', + ) + accept(client.state, result) + remove_journal(path) + return result + + +async def run_command(client, args, arguments): + if args.action == 'show': + saved = client.state.data.get('api_key') + return {k: v for k, v in (saved or {}).items() if k != 'value'} + if args.action in {'create', 'rotate'}: + ceiling = loads(args.ceiling.encode()) if args.ceiling else None + require(ceiling is None or isinstance(ceiling, list), 'invalid_ceiling') + result = await create(client, ttl=args.ttl, ceiling=ceiling, rotate=args.action == 'rotate') + if result.status == 'ok': + return { + 'api_key': result.data['credential_id'] + '.' + result.data['token'], + 'expires_at': result.data['expires_at'], + 'saved': True, + } + return result + saved = client.state.data.get('api_key') + require(saved is not None and client.state.signer is not None, 'signing_identity_required') + result = await client.call( + 'identity.key_revoke', {'key_id': saved['credential_id']}, signer=client.state.signer + ) + if result.status == 'ok': + client.state.data.pop('api_key', None) + client.state._save() + return wire(result) diff --git a/src/msg/client_oauth.py b/src/msg/client_oauth.py new file mode 100644 index 00000000..4a027562 --- /dev/null +++ b/src/msg/client_oauth.py @@ -0,0 +1,228 @@ +"""Device login and durable origin-bound sessions for browserless agents.""" + +import asyncio +import os +import stat +import sys +import time +import webbrowser +from datetime import timedelta + +from msg.atomic_file import durable_write +from msg.client_upgrade import locked_state +from msg.core.codec import canonical, loads, parse_time, unb64, wire +from msg.core.errors import Failure, require +from msg.security.oauth import DEVICE_GRANT +from msg.transports.client import GraphQLTransport, HTTPTransport, MCPHTTPTransport + + +def read_session(state): + path = state.directory / 'oauth-session.json' + if not path.exists() and not path.is_symlink(): + return None + try: + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + except OSError: + raise Failure('unsafe_oauth_session') from None + try: + info = os.fstat(fd) + require( + stat.S_ISREG(info.st_mode) + and info.st_uid == os.geteuid() + and info.st_nlink == 1 + and info.st_mode & 0o077 == 0 + and info.st_size <= 16384, + 'unsafe_oauth_session', + ) + with os.fdopen(fd, 'rb', closefd=False) as stream: + value = loads(stream.read(16385)) + finally: + os.close(fd) + require( + isinstance(value, dict) + and value.get('server') == state.server + and value.get('client_id') == 'msg-cli', + 'oauth_session_server_mismatch', + ) + return value + + +def save_session(client, value): + require( + value.get('token_type') == 'Bearer' + and isinstance(value.get('expires_in'), int) + and value['expires_in'] > 0 + and isinstance(value.get('subject_id'), str), + 'invalid_oauth_response', + ) + require( + client.state.subject is None or client.state.subject == value['subject_id'], + 'oauth_identity_mismatch', + ) + token = value.get('access_token', '') + id, sep, encoded = token.partition('.') + require( + sep and id.startswith('t_oauth_') and len(unb64(encoded, limit=32)) == 32, + 'invalid_oauth_response', + ) + saved = dict( + value, + server=client.state.server, + client_id='msg-cli', + expires_at=wire(client.clock() + timedelta(seconds=value['expires_in'])), + ) + durable_write(client.state.directory / 'oauth-session.json', canonical(saved), mode=0o600) + client.state.data['subject_id'] = value['subject_id'] + client.state._save() + return saved + + +def secure_transport(client): + require( + type(client.transport) in {HTTPTransport, GraphQLTransport, MCPHTTPTransport}, + 'secure_channel_required', + ) + from urllib.parse import urlsplit + + parsed = urlsplit(client.state.server) + require( + parsed.scheme == 'https' + or parsed.hostname in {'localhost', '127.0.0.1', '::1', 'testserver'}, + 'secure_channel_required', + ) + + +async def endpoint(client, path, data): + secure_transport(client) + try: + return await client.transport._json('POST', path, body=data) + except Failure as exc: + # OAuth errors are standard string-valued errors, unlike MSG envelopes. + raise exc + + +async def start_login( + client, *, scope='openid profile msg.read msg.write offline_access', browser=False +): + value = await endpoint( + client, '/oauth/device_authorization', {'client_id': 'msg-cli', 'scope': scope} + ) + require( + all( + isinstance(value.get(k), str) + for k in ('device_code', 'user_code', 'verification_uri', 'verification_uri_complete') + ), + 'invalid_oauth_response', + ) + require( + value['verification_uri'] == client.state.server + '/oauth/device' + and value['verification_uri_complete'].startswith(value['verification_uri'] + '?'), + 'oauth_response_origin_mismatch', + ) + if browser: + webbrowser.open(value['verification_uri_complete']) + return value + + +async def finish_login(client, pending): + secure_transport(client) + deadline = time.monotonic() + min(pending.get('expires_in', 600), 600) + interval = max(5, pending.get('interval', 5)) + while time.monotonic() < deadline: + await asyncio.sleep(interval) + try: + value = await endpoint( + client, + '/oauth/token', + { + 'client_id': 'msg-cli', + 'grant_type': DEVICE_GRANT, + 'device_code': pending['device_code'], + }, + ) + except Failure as exc: + if exc.code == 'authorization_pending': + continue + if exc.code == 'slow_down': + interval += 5 + continue + raise + with locked_state(client.state): + save_session(client, value) + return {'logged_in': True, 'subject_id': value['subject_id'], 'scope': value['scope']} + raise Failure('expired_token') + + +async def refresh(client): + secure_transport(client) + with locked_state(client.state): + current = read_session(client.state) + require(current is not None, 'oauth_session_missing') + if parse_time(current['expires_at']) > client.clock() + timedelta(seconds=30): + return current + require(current.get('refresh_token'), 'oauth_login_required') + # A failed/lost response is ambiguous after rotation. Keep the local key, + # require a fresh login, and never replay a consumed refresh token. + if current.get('refresh_pending'): + raise Failure('oauth_login_required') + current['refresh_pending'] = True + durable_write(client.state.directory / 'oauth-session.json', canonical(current), mode=0o600) + value = await endpoint( + client, + '/oauth/token', + { + 'client_id': 'msg-cli', + 'grant_type': 'refresh_token', + 'refresh_token': current['refresh_token'], + }, + ) + return save_session(client, value) + + +async def logout(client): + secure_transport(client) + with locked_state(client.state): + current = read_session(client.state) + if current is not None: + await endpoint( + client, + '/oauth/revoke', + { + 'client_id': 'msg-cli', + 'token': current.get('refresh_token') or current['access_token'], + }, + ) + from msg.client_tokens import remove_journal + + remove_journal(client.state.directory / 'oauth-session.json') + return {'logged_out': True} + + +async def run_command(client, args): + if args.command == 'login': + pending = await start_login(client, scope=args.scope, browser=not args.no_browser) + print( + '打开 ' + pending['verification_uri'] + ',输入 ' + pending['user_code'], + file=sys.stderr, + flush=True, + ) + return await finish_login(client, pending) + if args.command == 'logout': + return await logout(client) + if args.action == 'status': + current = read_session(client.state) + return { + 'logged_in': current is not None and not current.get('refresh_pending'), + 'subject_id': current.get('subject_id') if current else None, + 'expires_at': current.get('expires_at') if current else None, + 'scope': current.get('scope') if current else None, + } + if args.action == 'request': + return await client.call( + 'identity.oauth_request', {'user_code': args.user_code}, anonymous=True + ) + return await client.call( + 'identity.oauth_approve', + {'user_code': args.user_code, 'decision': 'deny' if args.action == 'deny' else 'approve'}, + signer=client.state.signer, + ) diff --git a/src/msg/client_tokens.py b/src/msg/client_tokens.py index 26a7ad1d..068a2674 100644 --- a/src/msg/client_tokens.py +++ b/src/msg/client_tokens.py @@ -9,6 +9,7 @@ from msg.core.errors import Failure, require JOURNAL_OPERATIONS = { + 'api-key-create.json': ('identity.token_create', 3), 'temporary.json': ('identity.temporary', 3), 'custodial-bootstrap.json': ('identity.custodial_create', 2), 'token-rotation.json': ('identity.token_rotate', 2), diff --git a/src/msg/config.py b/src/msg/config.py index 285444d4..55f8aa5f 100644 --- a/src/msg/config.py +++ b/src/msg/config.py @@ -12,6 +12,7 @@ from msg.config_contracts import configuration_keys, validate_sections from msg.core.errors import require from msg.core.models import MailConfig, ServerConfig, TransportLimits +from msg.oauth_config import OAuthConfig, load_oauth from msg.security.age_keys import encryption_key_id, public_from_recipient @@ -74,6 +75,7 @@ class Settings: hosting_base_capacity_bytes: int = 10 * 1024 * 1024 hosting_recovery_marker: Path | None = None hosting_content_group_read: bool = False + oauth: OAuthConfig = OAuthConfig() @property def config_dir(self): @@ -443,6 +445,7 @@ def load_settings(config_dir=Path('/etc/msgd')): hosting_base_capacity_bytes=hosting_base, hosting_recovery_marker=Path(recovery_marker) if recovery_marker is not None else None, hosting_content_group_read=content_group_read, + oauth=load_oauth(data.get('oauth', {}), service), ) diff --git a/src/msg/config_contracts.py b/src/msg/config_contracts.py index 71467c48..5aa50469 100644 --- a/src/msg/config_contracts.py +++ b/src/msg/config_contracts.py @@ -8,6 +8,10 @@ # section -> field -> (Settings attribute path, related doctor check) _SECTIONS = { + 'oauth': { + name: ('oauth.' + name, None) + for name in ('enabled', 'access_ttl', 'session_ttl', 'clients') + }, 'server': { 'service_url': ('service_url', None), 'listen': ('listen', None), diff --git a/src/msg/core/models.py b/src/msg/core/models.py index 34e89c2a..0687a681 100644 --- a/src/msg/core/models.py +++ b/src/msg/core/models.py @@ -178,6 +178,7 @@ class Credential: not_before: datetime expires_at: datetime | None revoked_at: datetime | None + source_credential_id: ID | None = field(default=None, metadata={'omit_if_none': True}) @dataclass(frozen=True, slots=True, kw_only=True) diff --git a/src/msg/data/recovery-state-schema.json b/src/msg/data/recovery-state-schema.json index 90058ea5..ddd74d2c 100644 --- a/src/msg/data/recovery-state-schema.json +++ b/src/msg/data/recovery-state-schema.json @@ -1 +1 @@ -{"constraints":[["achievement_ceremonies","achievement_ceremonies_pkey","p"],["achievement_grants","achievement_grants_pkey","p"],["achievement_grants","achievement_grants_subject_achievement_id_spec_version_key","u"],["achievement_pins","achievement_pins_grant_id_fkey","f"],["achievement_pins","achievement_pins_pkey","p"],["achievement_pins","achievement_pins_position_check","c"],["achievement_pins","achievement_pins_subject_fkey","f"],["achievement_pins","achievement_pins_subject_position_key","u"],["arbitration_cases","arbitration_cases_order_id_fkey","f"],["arbitration_cases","arbitration_cases_order_id_key","u"],["arbitration_cases","arbitration_cases_pkey","p"],["arbitration_decisions","arbitration_decisions_case_id_fkey","f"],["arbitration_decisions","arbitration_decisions_case_id_round_key","u"],["arbitration_decisions","arbitration_decisions_pkey","p"],["arbitration_evidence","arbitration_evidence_author_fkey","f"],["arbitration_evidence","arbitration_evidence_case_id_fkey","f"],["arbitration_evidence","arbitration_evidence_pkey","p"],["arbitration_policies","arbitration_policies_pkey","p"],["arbitration_votes","arbitration_votes_arbitrator_fkey","f"],["arbitration_votes","arbitration_votes_case_id_fkey","f"],["arbitration_votes","arbitration_votes_pkey","p"],["arbitrator_conflicts","arbitrator_conflicts_arbitrator_fkey","f"],["arbitrator_conflicts","arbitrator_conflicts_party_fkey","f"],["arbitrator_conflicts","arbitrator_conflicts_pkey","p"],["arbitrator_roles","arbitrator_roles_pkey","p"],["arbitrator_roles","arbitrator_roles_subject_fkey","f"],["audit","audit_digest_key","u"],["audit","audit_pkey","p"],["batches","batches_pkey","p"],["bounty_challenges","bounty_challenges_claimant_fkey","f"],["bounty_challenges","bounty_challenges_listing_id_fkey","f"],["bounty_challenges","bounty_challenges_pkey","p"],["bounty_challenges","bounty_challenges_verifier_version_check","c"],["bounty_claims","bounty_claims_challenge_id_fkey","f"],["bounty_claims","bounty_claims_challenge_id_key","u"],["bounty_claims","bounty_claims_claimant_fkey","f"],["bounty_claims","bounty_claims_listing_id_fkey","f"],["bounty_claims","bounty_claims_pkey","p"],["bounty_claims","bounty_claims_reward_minor_check","c"],["bounty_claims","bounty_claims_status_check","c"],["bounty_claims","bounty_claims_transaction_id_fkey","f"],["bounty_claims","bounty_claims_transaction_id_key","u"],["bounty_listings","bounty_listings_budget_minor_check","c"],["bounty_listings","bounty_listings_claim_limit_per_subject_check","c"],["bounty_listings","bounty_listings_escrow_account_fkey","f"],["bounty_listings","bounty_listings_escrow_subject_key","u"],["bounty_listings","bounty_listings_listing_id_fkey","f"],["bounty_listings","bounty_listings_max_claims_check","c"],["bounty_listings","bounty_listings_pkey","p"],["bounty_listings","bounty_listings_publisher_fkey","f"],["bounty_listings","bounty_listings_reward_minor_check","c"],["bounty_listings","bounty_listings_state_check","c"],["bounty_listings","bounty_listings_verifier_version_check","c"],["certificates","certificates_pkey","p"],["chunks","chunks_pkey","p"],["claims","claims_id_fkey","f"],["claims","claims_pkey","p"],["collaboration_leases","collaboration_leases_pkey","p"],["collaboration_leases","collaboration_leases_status_check","c"],["collaboration_leases","collaboration_leases_target_fkey","f"],["credentials","credentials_pkey","p"],["csrs","csrs_pkey","p"],["custodial_upgrades","custodial_upgrades_pkey","p"],["custodial_upgrades","custodial_upgrades_status_check","c"],["custodial_vault","custodial_vault_lifecycle_check","c"],["custodial_vault","custodial_vault_pkey","p"],["delivery_email_challenges","delivery_email_challenges_order_id_fkey","f"],["delivery_email_challenges","delivery_email_challenges_pkey","p"],["delivery_email_challenges","delivery_email_challenges_subject_fkey","f"],["delivery_email_endpoints","delivery_email_endpoints_pkey","p"],["delivery_email_endpoints","delivery_email_endpoints_subject_fkey","f"],["delivery_envelopes","delivery_envelopes_order_id_fkey","f"],["delivery_envelopes","delivery_envelopes_pkey","p"],["dm_archives","dm_archives_pair_fkey","f"],["dm_archives","dm_archives_pkey","p"],["dm_blocks","dm_blocks_pkey","p"],["dm_conversations","dm_conversations_check","c"],["dm_conversations","dm_conversations_conversation_kind_check","c"],["dm_conversations","dm_conversations_pkey","p"],["dm_conversations","dm_conversations_resource_id_fkey","f"],["dm_conversations","dm_conversations_resource_id_key","u"],["dm_conversations","dm_conversations_state_check","c"],["email_challenges","email_challenges_pkey","p"],["emails","emails_pkey","p"],["encryption_subkeys","encryption_subkeys_pkey","p"],["encryption_subkeys","encryption_subkeys_recipient_key","u"],["events","events_id_key","u"],["events","events_pkey","p"],["handoffs","handoffs_pkey","p"],["handoffs","handoffs_status_check","c"],["identities","identities_pkey","p"],["identity_keys","identity_keys_pkey","p"],["jobs","jobs_dedupe_key","u"],["jobs","jobs_pkey","p"],["ledger_accounts","ledger_accounts_check","c"],["ledger_accounts","ledger_accounts_kind_check","c"],["ledger_accounts","ledger_accounts_kind_source_id_key","u"],["ledger_accounts","ledger_accounts_pkey","p"],["ledger_accounts","ledger_accounts_subject_id_fkey","f"],["legacy_directive_versions","legacy_directive_versions_pkey","p"],["legacy_directive_versions","legacy_directive_versions_revision_id_fkey","f"],["legacy_directives","legacy_directives_pkey","p"],["legacy_directives","legacy_directives_resource_id_fkey","f"],["legacy_directives","legacy_directives_resource_id_key","u"],["legacy_states","legacy_states_pkey","p"],["legacy_states","legacy_states_state_check","c"],["memberships","memberships_pkey","p"],["messages","messages_pkey","p"],["money_accounts","money_accounts_currency_id_check","c"],["money_accounts","money_accounts_ledger_account_fkey","f"],["money_accounts","money_accounts_pkey","p"],["money_bank_roles","money_bank_roles_granted_by_check","c"],["money_bank_roles","money_bank_roles_pkey","p"],["money_bank_roles","money_bank_roles_status_check","c"],["money_bank_roles","money_bank_roles_subject_id_fkey","f"],["money_ledger","money_ledger_actor_request_id_entry_key_key","u"],["money_ledger","money_ledger_amount_minor_check","c"],["money_ledger","money_ledger_check","c"],["money_ledger","money_ledger_credit_ledger_account_fkey","f"],["money_ledger","money_ledger_currency_id_check","c"],["money_ledger","money_ledger_debit_ledger_account_fkey","f"],["money_ledger","money_ledger_id_key","u"],["money_ledger","money_ledger_kind_check","c"],["money_ledger","money_ledger_pkey","p"],["money_ledger","money_ledger_policy_version_check","c"],["money_purchases","money_purchases_entitlement_id_fkey","f"],["money_purchases","money_purchases_escrow_account_fkey","f"],["money_purchases","money_purchases_escrow_account_key","u"],["money_purchases","money_purchases_final_transaction_id_fkey","f"],["money_purchases","money_purchases_funding_transaction_id_fkey","f"],["money_purchases","money_purchases_pkey","p"],["money_purchases","money_purchases_quantity_check","c"],["money_purchases","money_purchases_state_check","c"],["money_purchases","money_purchases_subject_id_fkey","f"],["money_purchases","money_purchases_subject_id_request_id_key","u"],["money_purchases","money_purchases_total_minor_check","c"],["order_contracts","order_contracts_order_id_fkey","f"],["order_contracts","order_contracts_pkey","p"],["order_deadlines","order_deadlines_order_id_fkey","f"],["order_deadlines","order_deadlines_pkey","p"],["order_escrow_decisions","order_escrow_decisions_id_key","u"],["order_escrow_decisions","order_escrow_decisions_order_id_fkey","f"],["order_escrow_decisions","order_escrow_decisions_pkey","p"],["order_escrow_decisions","order_escrow_decisions_transaction_id_fkey","f"],["order_escrow_decisions","order_escrow_decisions_transaction_id_key","u"],["order_settlements","order_settlements_decision_id_key","u"],["order_settlements","order_settlements_order_id_fkey","f"],["order_settlements","order_settlements_pkey","p"],["order_transitions","order_transitions_order_id_fkey","f"],["order_transitions","order_transitions_pkey","p"],["personal_revision_proofs","personal_revision_proofs_pkey","p"],["personal_revision_proofs","personal_revision_proofs_revision_id_fkey","f"],["presence","presence_pkey","p"],["projections","projections_pkey","p"],["reactions","reactions_pkey","p"],["recovery_envelopes","recovery_envelopes_owner_ciphertext_resource_ciphertext_rev_key","u"],["recovery_envelopes","recovery_envelopes_pkey","p"],["recovery_policies","recovery_policies_pkey","p"],["relations","relations_revision_id_fkey","f"],["resource_entitlements","resource_entitlements_offer_id_fkey","f"],["resource_entitlements","resource_entitlements_pkey","p"],["resource_entitlements","resource_entitlements_quantity_check","c"],["resource_entitlements","resource_entitlements_redeem_transaction_id_fkey","f"],["resource_entitlements","resource_entitlements_redeem_transaction_id_key","u"],["resource_entitlements","resource_entitlements_subject_id_fkey","f"],["resource_entitlements","resource_entitlements_subject_id_purchase_request_id_key","u"],["resource_path_aliases","resource_path_aliases_parent_id_fkey","f"],["resource_path_aliases","resource_path_aliases_pkey","p"],["resource_path_aliases","resource_path_aliases_resource_id_fkey","f"],["resource_tags","resource_tags_pkey","p"],["resource_tags","resource_tags_resource_id_fkey","f"],["resources","resources_parent_fkey","f"],["resources","resources_parent_name_key","u"],["resources","resources_pkey","p"],["results","results_pkey","p"],["revisions","revisions_pkey","p"],["revisions","revisions_resource_id_fkey","f"],["schema_version","schema_version_pkey","p"],["server_offer_resources","server_offer_resources_listing_id_fkey","f"],["server_offer_resources","server_offer_resources_listing_id_key","u"],["server_offer_resources","server_offer_resources_offer_id_fkey","f"],["server_offer_resources","server_offer_resources_pkey","p"],["server_offers","server_offers_check","c"],["server_offers","server_offers_duration_seconds_check","c"],["server_offers","server_offers_min_quantity_check","c"],["server_offers","server_offers_pkey","p"],["server_offers","server_offers_price_minor_check","c"],["settings","settings_pkey","p"],["share_grants","share_grants_pkey","p"],["share_grants","share_grants_resource_id_fkey","f"],["share_grants_v2","share_grants_v2_allow_reshare_check","c"],["share_grants_v2","share_grants_v2_grantee_kind_check","c"],["share_grants_v2","share_grants_v2_parent_id_fkey","f"],["share_grants_v2","share_grants_v2_pkey","p"],["share_grants_v2","share_grants_v2_resource_id_fkey","f"],["share_links","share_links_pkey","p"],["share_links","share_links_resource_id_fkey","f"],["share_links","share_links_verifier_key","u"],["store_deliveries","store_deliveries_channel_check","c"],["store_deliveries","store_deliveries_order_id_fkey","f"],["store_deliveries","store_deliveries_order_id_key","u"],["store_deliveries","store_deliveries_pkey","p"],["store_deliveries","store_deliveries_recipient_subject_fkey","f"],["store_deliveries","store_deliveries_state_check","c"],["store_order_events","store_order_events_order_id_fkey","f"],["store_order_events","store_order_events_pkey","p"],["store_orders","store_orders_buyer_fkey","f"],["store_orders","store_orders_currency_id_check","c"],["store_orders","store_orders_escrow_account_fkey","f"],["store_orders","store_orders_escrow_subject_key","u"],["store_orders","store_orders_listing_id_fkey","f"],["store_orders","store_orders_payment_transaction_id_fkey","f"],["store_orders","store_orders_payment_transaction_id_key","u"],["store_orders","store_orders_pkey","p"],["store_orders","store_orders_quantity_check","c"],["store_orders","store_orders_seller_fkey","f"],["store_orders","store_orders_state_check","c"],["store_orders","store_orders_total_price_minor_check","c"],["store_orders","store_orders_unit_price_minor_check","c"],["store_packages","store_packages_pkey","p"],["store_packages","store_packages_revision_key","u"],["sync_checkpoints","sync_checkpoints_pkey","p"],["system_sources","system_sources_pkey","p"],["system_sources","system_sources_resource_id_fkey","f"],["system_sources","system_sources_revision_id_fkey","f"],["system_sources","system_sources_rule_id_key","u"],["system_sources","system_sources_source_path_key","u"],["token_deliveries","token_deliveries_pkey","p"],["topic_bans","topic_bans_pkey","p"],["topic_bans","topic_bans_status_check","c"],["topic_bans","topic_bans_topic_fkey","f"],["topic_event_projection","topic_event_projection_pkey","p"],["topic_event_projection","topic_event_projection_seq_fkey","f"],["topic_memberships","topic_memberships_pkey","p"],["topic_memberships","topic_memberships_role_check","c"],["topic_memberships","topic_memberships_status_check","c"],["topic_memberships","topic_memberships_topic_fkey","f"],["topic_settings","topic_settings_membership_policy_check","c"],["topic_settings","topic_settings_pkey","p"],["topic_settings","topic_settings_topic_fkey","f"],["transfers","transfers_pkey","p"],["watches","watches_pkey","p"],["webhook_endpoints","webhook_endpoints_enabled_check","c"],["webhook_endpoints","webhook_endpoints_pkey","p"]],"functions":[["msg_audit_append_only",""],["msg_escrow_decision_append_only",""],["msg_money_ledger_append_only",""],["msg_register_subject_ledger_account",""]],"indexes":[["achievement_ceremonies","achievement_ceremonies_pkey"],["achievement_ceremonies","achievement_ceremonies_subject"],["achievement_grants","achievement_grants_lookup"],["achievement_grants","achievement_grants_pkey"],["achievement_grants","achievement_grants_subject_achievement_id_spec_version_key"],["achievement_pins","achievement_pins_pkey"],["achievement_pins","achievement_pins_subject_position_key"],["arbitration_cases","arbitration_cases_order_id_key"],["arbitration_cases","arbitration_cases_pkey"],["arbitration_decisions","arbitration_decisions_case_id_round_key"],["arbitration_decisions","arbitration_decisions_pkey"],["arbitration_evidence","arbitration_evidence_pkey"],["arbitration_policies","arbitration_policies_pkey"],["arbitration_votes","arbitration_votes_pkey"],["arbitrator_conflicts","arbitrator_conflicts_pkey"],["arbitrator_roles","arbitrator_roles_pkey"],["audit","audit_digest_key"],["audit","audit_pkey"],["batches","batches_pkey"],["bounty_challenges","bounty_challenges_claimant"],["bounty_challenges","bounty_challenges_pkey"],["bounty_claims","bounty_claims_challenge_id_key"],["bounty_claims","bounty_claims_claimant"],["bounty_claims","bounty_claims_pkey"],["bounty_claims","bounty_claims_transaction_id_key"],["bounty_listings","bounty_listings_escrow_subject_key"],["bounty_listings","bounty_listings_pkey"],["bounty_listings","bounty_listings_publisher"],["certificates","certificates_pkey"],["chunks","chunks_pkey"],["claims","claims_pkey"],["claims","claims_subject"],["collaboration_leases","collaboration_leases_holder"],["collaboration_leases","collaboration_leases_pkey"],["credentials","credentials_pkey"],["credentials","credentials_subject"],["csrs","csrs_pkey"],["custodial_upgrades","custodial_upgrades_pkey"],["custodial_upgrades","custodial_upgrades_subject"],["custodial_vault","custodial_vault_pkey"],["delivery_email_challenges","delivery_email_challenges_pkey"],["delivery_email_endpoints","delivery_email_endpoints_pkey"],["delivery_envelopes","delivery_envelopes_pkey"],["dm_archives","dm_archives_pkey"],["dm_blocks","dm_blocks_pkey"],["dm_conversations","dm_conversations_participants"],["dm_conversations","dm_conversations_pkey"],["dm_conversations","dm_conversations_resource_id_key"],["email_challenges","email_challenges_pkey"],["emails","emails_pkey"],["encryption_subkeys","encryption_subkeys_pkey"],["encryption_subkeys","encryption_subkeys_primary"],["encryption_subkeys","encryption_subkeys_recipient_key"],["encryption_subkeys","encryption_subkeys_subject"],["events","events_id_key"],["events","events_pkey"],["handoffs","handoffs_from"],["handoffs","handoffs_pkey"],["handoffs","handoffs_to"],["identities","identities_pkey"],["identity_keys","identity_keys_pkey"],["identity_keys","identity_keys_primary"],["identity_keys","identity_keys_subject"],["jobs","jobs_dedupe_key"],["jobs","jobs_pkey"],["ledger_accounts","ledger_accounts_kind_source_id_key"],["ledger_accounts","ledger_accounts_pkey"],["legacy_directive_versions","legacy_directive_versions_pkey"],["legacy_directives","legacy_directives_pkey"],["legacy_directives","legacy_directives_resource_id_key"],["legacy_states","legacy_states_pkey"],["memberships","memberships_pkey"],["messages","messages_pkey"],["money_accounts","money_accounts_pkey"],["money_bank_roles","money_bank_roles_pkey"],["money_ledger","money_ledger_actor_request_id_entry_key_key"],["money_ledger","money_ledger_credit"],["money_ledger","money_ledger_debit"],["money_ledger","money_ledger_id_key"],["money_ledger","money_ledger_pkey"],["money_ledger","money_ledger_request_leg"],["money_purchases","money_purchases_escrow_account_key"],["money_purchases","money_purchases_pkey"],["money_purchases","money_purchases_subject_id_request_id_key"],["order_contracts","order_contracts_pkey"],["order_deadlines","order_deadlines_due"],["order_deadlines","order_deadlines_pkey"],["order_escrow_decisions","order_escrow_decisions_id_key"],["order_escrow_decisions","order_escrow_decisions_pkey"],["order_escrow_decisions","order_escrow_decisions_transaction_id_key"],["order_settlements","order_settlements_decision_id_key"],["order_settlements","order_settlements_pkey"],["order_transitions","order_transitions_pkey"],["personal_revision_proofs","personal_revision_proofs_pkey"],["presence","presence_pkey"],["projections","projections_pkey"],["reactions","reactions_pkey"],["recovery_envelopes","recovery_envelopes_owner"],["recovery_envelopes","recovery_envelopes_owner_ciphertext_resource_ciphertext_rev_key"],["recovery_envelopes","recovery_envelopes_pkey"],["recovery_policies","recovery_policies_pkey"],["relations","relations_target"],["resource_entitlements","resource_entitlements_pkey"],["resource_entitlements","resource_entitlements_redeem_transaction_id_key"],["resource_entitlements","resource_entitlements_subject_id_purchase_request_id_key"],["resource_path_aliases","resource_path_aliases_pkey"],["resource_tags","resource_tags_pkey"],["resource_tags","resource_tags_resource"],["resources","one_root"],["resources","resources_owner"],["resources","resources_parent"],["resources","resources_parent_name_key"],["resources","resources_pkey"],["resources","resources_time"],["resources","resources_type_state"],["results","results_pkey"],["revisions","revisions_pkey"],["revisions","revisions_resource"],["schema_version","schema_version_pkey"],["server_offer_resources","server_offer_resources_listing_id_key"],["server_offer_resources","server_offer_resources_pkey"],["server_offers","server_offers_pkey"],["settings","settings_pkey"],["share_grants","share_grants_active"],["share_grants","share_grants_grantee"],["share_grants","share_grants_pkey"],["share_grants_v2","share_grants_v2_parent"],["share_grants_v2","share_grants_v2_pkey"],["share_grants_v2","share_grants_v2_resource"],["share_links","share_links_pkey"],["share_links","share_links_resource"],["share_links","share_links_verifier_key"],["store_deliveries","store_deliveries_order_id_key"],["store_deliveries","store_deliveries_pkey"],["store_order_events","store_order_events_order"],["store_order_events","store_order_events_pkey"],["store_orders","store_orders_buyer"],["store_orders","store_orders_escrow_subject_key"],["store_orders","store_orders_listing"],["store_orders","store_orders_payment_transaction_id_key"],["store_orders","store_orders_pkey"],["store_orders","store_orders_seller"],["store_packages","store_packages_pkey"],["store_packages","store_packages_revision_key"],["sync_checkpoints","sync_checkpoints_pkey"],["sync_checkpoints","sync_checkpoints_subject"],["system_sources","system_sources_pkey"],["system_sources","system_sources_rule_id_key"],["system_sources","system_sources_source_path_key"],["token_deliveries","token_deliveries_pkey"],["token_deliveries","token_deliveries_subject"],["topic_bans","topic_bans_pkey"],["topic_event_projection","topic_event_projection_pkey"],["topic_event_projection","topic_event_projection_topic"],["topic_memberships","topic_memberships_pkey"],["topic_memberships","topic_memberships_subject"],["topic_settings","topic_settings_pkey"],["transfers","transfers_pkey"],["watches","watches_pkey"],["webhook_endpoints","webhook_endpoints_pkey"]],"sequences":[["audit_seq_seq","bigint"],["events_seq_seq","bigint"],["money_ledger_seq_seq","bigint"],["store_order_events_seq_seq","bigint"]],"tables":{"achievement_ceremonies":[["id","text",true,""],["subject","text",true,""],["state","text",true,""],["body","text",true,""]],"achievement_grants":[["id","text",true,""],["subject","text",true,""],["achievement_id","text",true,""],["spec_version","integer",true,""],["body","text",true,""]],"achievement_pins":[["subject","text",true,""],["grant_id","text",true,""],["position","integer",true,""]],"arbitration_cases":[["id","text",true,""],["order_id","text",true,""],["round","integer",true,""],["state","text",true,""],["deadline","text",true,""],["body","text",true,""]],"arbitration_decisions":[["id","text",true,""],["case_id","text",true,""],["round","integer",true,""],["body","text",true,""],["revoked","boolean",true,""]],"arbitration_evidence":[["id","text",true,""],["case_id","text",true,""],["author","text",true,""],["visibility","text",true,""],["body","text",true,""]],"arbitration_policies":[["id","text",true,""],["body","text",true,""],["digest","text",true,""]],"arbitration_votes":[["case_id","text",true,""],["round","integer",true,""],["arbitrator","text",true,""],["body","text",true,""]],"arbitrator_conflicts":[["arbitrator","text",true,""],["party","text",true,""]],"arbitrator_roles":[["subject","text",true,""],["epoch","text",true,""],["active","boolean",true,""]],"audit":[["seq","bigint",true,"d"],["digest","text",true,""],["previous","text",false,""],["body","text",true,""]],"batches":[["subject","text",true,""],["request_id","text",true,""],["digest","text",true,""]],"bounty_challenges":[["id","text",true,""],["listing_id","text",true,""],["claimant","text",true,""],["key_id","text",true,""],["nonce","text",true,""],["issued_at","text",true,""],["expires_at","text",true,""],["verifier_version","integer",true,""],["payload","text",true,""],["consumed_at","text",false,""]],"bounty_claims":[["id","text",true,""],["listing_id","text",true,""],["claimant","text",true,""],["challenge_id","text",true,""],["proof_digest","text",true,""],["status","text",true,""],["reward_minor","bigint",true,""],["transaction_id","text",true,""],["claimed_at","text",true,""]],"bounty_listings":[["listing_id","text",true,""],["publisher","text",true,""],["escrow_subject","text",true,""],["reward_minor","bigint",true,""],["budget_minor","bigint",true,""],["max_claims","integer",true,""],["claim_limit_per_subject","integer",true,""],["verifier_id","text",true,""],["verifier_version","integer",true,""],["eligibility","text",true,""],["state","text",true,""],["pause_reason","text",false,""],["expires_at","text",false,""],["created_at","text",true,""]],"certificates":[["id","text",true,""],["subject","text",true,""],["parent","text",false,""],["revoked","integer",true,""],["body","text",true,""]],"chunks":[["transfer_id","text",true,""],["offset","bigint",true,""],["length","bigint",true,""],["body","text",true,""]],"claims":[["id","text",true,""],["subject","text",true,""],["issued_at","text",true,""],["expires_at","text",false,""],["body","text",true,""]],"collaboration_leases":[["id","text",true,""],["holder","text",true,""],["target","text",true,""],["status","text",true,""],["generation","integer",true,""],["expires_at","text",true,""],["body","text",true,""]],"credentials":[["id","text",true,""],["subject","text",true,""],["body","text",true,""]],"csrs":[["id","text",true,""],["generation","integer",true,""],["state","text",true,""],["body","text",true,""],["state_body","text",true,""]],"custodial_upgrades":[["id","text",true,""],["subject","text",true,""],["credential_id","text",true,""],["status","text",true,""],["expires_at","text",true,""],["challenge","text",true,""],["ephemeral_nonce","text",false,""],["ephemeral_ciphertext","text",false,""],["body","text",true,""]],"custodial_vault":[["subject","text",true,""],["signing_key_id","text",true,""],["encryption_key_id","text",true,""],["signing_nonce","text",false,""],["signing_ciphertext","text",false,""],["age_nonce","text",false,""],["age_ciphertext","text",false,""],["status","text",true,""],["created_at","text",true,""],["destroyed_at","text",false,""]],"delivery_email_challenges":[["id","text",true,""],["order_id","text",true,""],["subject","text",true,""],["address","text",true,""],["verifier","text",true,""],["expires_at","text",true,""],["consumed","boolean",true,""]],"delivery_email_endpoints":[["id","text",true,""],["subject","text",true,""],["address","text",true,""],["verified_at","text",true,""],["active","boolean",true,""]],"delivery_envelopes":[["order_id","text",true,""],["body","text",true,""]],"dm_archives":[["subject","text",true,""],["pair","text",true,""]],"dm_blocks":[["blocker","text",true,""],["blocked","text",true,""]],"dm_conversations":[["pair","text",true,""],["resource_id","text",true,""],["participant_a","text",true,""],["participant_b","text",true,""],["initiator","text",true,""],["conversation_kind","text",true,""],["state","text",true,""],["created_at","text",true,""],["updated_at","text",true,""]],"email_challenges":[["subject","text",true,""],["digest","text",true,""],["expires","text",true,""]],"emails":[["subject","text",true,""],["generation","integer",true,""],["body","text",true,""]],"encryption_subkeys":[["key_id","text",true,""],["subject","text",true,""],["recipient","text",true,""],["public_key","text",true,""],["created_at","text",true,""],["retired_at","text",false,""],["is_primary","integer",true,""]],"events":[["seq","bigint",true,"d"],["id","text",true,""],["body","text",true,""]],"handoffs":[["id","text",true,""],["from_subject","text",true,""],["to_subject","text",true,""],["status","text",true,""],["generation","integer",true,""],["body","text",true,""]],"identities":[["id","text",true,""],["kind","text",true,""],["generation","integer",true,""],["body","text",true,""]],"identity_keys":[["key_id","text",true,""],["subject","text",true,""],["public_key","text",true,""],["created_at","text",true,""],["retired_at","text",false,""],["is_primary","integer",true,""]],"jobs":[["id","text",true,""],["dedupe","text",true,""],["kind","text",true,""],["state","text",true,""],["next_at","text",true,""],["body","text",true,""]],"ledger_accounts":[["id","text",true,""],["kind","text",true,""],["subject_id","text",false,""],["source_id","text",false,""]],"legacy_directive_versions":[["revision_id","text",true,""],["resource_id","text",true,""],["subject","text",true,""],["body","text",true,""]],"legacy_directives":[["subject","text",true,""],["resource_id","text",true,""],["created_at","text",true,""]],"legacy_states":[["subject","text",true,""],["state","text",true,""]],"memberships":[["org","text",true,""],["subject","text",true,""],["generation","integer",true,""],["body","text",true,""]],"messages":[["id","text",true,""],["sender","text",false,""],["recipient","text",false,""],["resource","text",false,""],["event_id","text",false,""],["body","text",true,""]],"money_accounts":[["subject_id","text",true,""],["currency_id","text",true,""]],"money_bank_roles":[["subject_id","text",true,""],["status","text",true,""],["granted_at","text",true,""],["granted_by","text",true,""],["revoked_at","text",false,""]],"money_ledger":[["seq","bigint",true,"d"],["id","text",true,""],["kind","text",true,""],["currency_id","text",true,""],["amount_minor","bigint",true,""],["debit_account","text",false,""],["credit_account","text",false,""],["actor","text",true,""],["request_id","text",true,""],["reference","text",false,""],["committed_at","text",true,""],["policy_version","integer",true,""],["policy_digest","text",true,""],["receipt","text",true,""],["entry_key","text",true,""]],"money_purchases":[["id","text",true,""],["subject_id","text",true,""],["request_id","text",true,""],["offer_snapshot","text",true,""],["quantity","bigint",true,""],["total_minor","bigint",true,""],["escrow_account","text",true,""],["state","text",true,""],["created_at","text",true,""],["expires_at","text",true,""],["funding_transaction_id","text",true,""],["final_transaction_id","text",false,""],["entitlement_id","text",false,""],["reason","text",false,""]],"order_contracts":[["order_id","text",true,""],["body","text",true,""],["digest","text",true,""]],"order_deadlines":[["order_id","text",true,""],["expires_at","text",true,""]],"order_escrow_decisions":[["order_id","text",true,""],["id","text",true,""],["body","text",true,""],["signature","text",true,""],["source_proof","text",true,""],["transaction_id","text",true,""]],"order_settlements":[["order_id","text",true,""],["decision_id","text",false,""],["body","text",true,""]],"order_transitions":[["id","text",true,""],["order_id","text",true,""],["body","text",true,""]],"personal_revision_proofs":[["revision_id","text",true,""],["subject","text",true,""],["kind","text",true,""],["signature","text",true,""],["signed_envelope","text",true,""],["created_at","text",true,""]],"presence":[["subject","text",true,""],["expires_at","text",true,""],["body","text",true,""]],"projections":[["resource_id","text",true,""],["text","text",true,""]],"reactions":[["subject","text",true,""],["resource","text",true,""],["kind","text",true,""],["revision","text",true,""],["body","text",true,""]],"recovery_envelopes":[["id","text",true,""],["owner","text",true,""],["ciphertext_resource","text",true,""],["ciphertext_revision","text",true,""],["policy_version","integer",true,""],["encryption_key_id","text",true,""],["purpose","text",true,""],["created_at","text",true,""],["body","text",true,""]],"recovery_policies":[["subject","text",true,""],["version","integer",true,""],["encryption_key_id","text",true,""],["created_at","text",true,""],["body","text",true,""]],"relations":[["revision_id","text",true,""],["source_id","text",true,""],["type","text",true,""],["target_id","text",true,""],["target_revision","text",false,""],["body","text",true,""]],"resource_entitlements":[["id","text",true,""],["subject_id","text",true,""],["offer_id","text",true,""],["purchase_request_id","text",true,""],["quantity","bigint",true,""],["entitlement_kind","text",true,""],["granted_at","text",true,""],["expires_at","text",false,""],["redeem_transaction_id","text",false,""]],"resource_path_aliases":[["parent_id","text",true,""],["name","text",true,""],["resource_id","text",true,""]],"resource_tags":[["resource_id","text",true,""],["tag","text",true,""]],"resources":[["id","text",true,""],["type","text",true,""],["name","text",true,""],["parent","text",false,""],["owner","text",true,""],["grp","text",true,""],["mode","integer",true,""],["generation","integer",true,""],["revision","text",false,""],["state","text",true,""],["created_at","text",true,""],["modified_at","text",true,""],["body","text",true,""]],"results":[["subject","text",true,""],["request_id","text",true,""],["digest","text",true,""],["body","text",true,""]],"revisions":[["id","text",true,""],["resource_id","text",true,""],["created_at","text",true,""],["body","text",true,""]],"schema_version":[["version","integer",true,""]],"server_offer_resources":[["offer_id","text",true,""],["listing_id","text",true,""]],"server_offers":[["offer_id","text",true,""],["resource_kind","text",true,""],["unit","text",true,""],["price_minor","bigint",true,""],["min_quantity","bigint",true,""],["max_quantity","bigint",true,""],["entitlement_kind","text",true,""],["duration_seconds","bigint",false,""],["enabled","boolean",true,""],["price_revision","text",true,""]],"settings":[["key","text",true,""],["value","text",true,""]],"share_grants":[["id","text",true,""],["resource_id","text",true,""],["grantor","text",true,""],["grantee","text",true,""],["created_at","text",true,""],["expires_at","text",true,""],["revoked_at","text",false,""]],"share_grants_v2":[["id","text",true,""],["resource_id","text",true,""],["grantor","text",true,""],["grantee","text",true,""],["grantee_kind","text",true,""],["parent_id","text",false,""],["operations","text",true,""],["constraints","text",true,""],["allow_reshare","integer",true,""],["created_at","text",true,""],["expires_at","text",true,""],["revoked_at","text",false,""]],"share_links":[["id","text",true,""],["resource_id","text",true,""],["grantor","text",true,""],["credential_id","text",true,""],["verifier","text",true,""],["created_at","text",true,""],["expires_at","text",true,""],["revoked_at","text",false,""]],"store_deliveries":[["id","text",true,""],["order_id","text",true,""],["recipient_subject","text",true,""],["kind","text",true,""],["payload_refs","text",true,""],["manifest","text",true,""],["package_digest","text",false,""],["delivery_digest","text",true,""],["channel","text",true,""],["state","text",true,""],["prepared_at","text",true,""],["claimed_at","text",false,""],["receipt","text",false,""]],"store_order_events":[["seq","bigint",true,"d"],["order_id","text",true,""],["from_state","text",false,""],["to_state","text",true,""],["reason","text",true,""],["actor","text",true,""],["request_id","text",true,""],["occurred_at","text",true,""],["receipt_refs","text",true,""]],"store_orders":[["id","text",true,""],["buyer","text",true,""],["seller","text",true,""],["listing_id","text",true,""],["listing_revision","text",true,""],["package_id","text",false,""],["package_revision","text",false,""],["package_digest","text",false,""],["quantity","bigint",true,""],["unit_price_minor","bigint",true,""],["total_price_minor","bigint",true,""],["currency_id","text",true,""],["escrow_subject","text",true,""],["escrow_policy","text",true,""],["dispute_policy","text",true,""],["terms_digest","text",true,""],["delivery_target","text",true,""],["payment_intent_digest","text",true,""],["payment_transaction_id","text",false,""],["state","text",true,""],["created_at","text",true,""],["funded_at","text",false,""],["delivered_at","text",false,""],["settled_at","text",false,""],["receipt_refs","text",true,""],["delivery_mode","text",true,""],["deadline_at","text",false,""],["quote_digest","text",false,""]],"store_packages":[["id","text",true,""],["listing_id","text",true,""],["listing_revision","text",true,""],["seller","text",true,""],["revision","text",true,""],["kind","text",true,""],["manifest","text",true,""],["payload_refs","text",true,""],["digest","text",true,""],["total_size","bigint",true,""],["delivery_mode","text",true,""],["deposited_at","text",true,""]],"sync_checkpoints":[["id","text",true,""],["subject","text",true,""],["credential_id","text",true,""],["version","integer",true,""],["expires_at","text",true,""],["body","text",true,""]],"system_sources":[["resource_id","text",true,""],["source_path","text",true,""],["rule_id","text",true,""],["source_kind","text",true,""],["source_version","integer",true,""],["source_digest","text",true,""],["revision_id","text",true,""]],"token_deliveries":[["credential_id","text",true,""],["subject","text",true,""],["request_id","text",true,""],["request_digest","text",true,""],["recovery_verifier","text",true,""],["recovery_expires_at","text",true,""],["claimed_at","text",false,""],["consumed_at","text",false,""]],"topic_bans":[["topic","text",true,""],["subject","text",true,""],["actor","text",true,""],["created_at","text",true,""],["expires_at","text",false,""],["reason","text",false,""],["status","text",true,""]],"topic_event_projection":[["seq","bigint",true,""],["topic","text",true,""]],"topic_memberships":[["topic","text",true,""],["subject","text",true,""],["role","text",true,""],["status","text",true,""],["joined_at","text",false,""],["invited_by","text",false,""]],"topic_settings":[["topic","text",true,""],["membership_policy","text",true,""]],"transfers":[["id","text",true,""],["subject","text",true,""],["generation","integer",true,""],["body","text",true,""],["limits","text",true,""]],"watches":[["subject","text",true,""],["resource","text",true,""]],"webhook_endpoints":[["subject","text",true,""],["url","text",true,""],["nonce","text",true,""],["ciphertext","text",true,""],["enabled","integer",true,""],["generation","integer",true,""]]},"triggers":[["arbitration_policies","arbitration_policies_append_only"],["arbitration_votes","arbitration_votes_append_only"],["audit","audit_no_update"],["money_accounts","money_account_register_subject"],["money_ledger","money_ledger_no_update"],["order_contracts","order_contracts_append_only"],["order_escrow_decisions","escrow_decision_no_update"],["order_settlements","order_settlements_append_only"],["order_transitions","order_transitions_append_only"],["store_order_events","store_order_events_append_only"]]} +{"constraints":[["achievement_ceremonies","achievement_ceremonies_pkey","p"],["achievement_grants","achievement_grants_pkey","p"],["achievement_grants","achievement_grants_subject_achievement_id_spec_version_key","u"],["achievement_pins","achievement_pins_grant_id_fkey","f"],["achievement_pins","achievement_pins_pkey","p"],["achievement_pins","achievement_pins_position_check","c"],["achievement_pins","achievement_pins_subject_fkey","f"],["achievement_pins","achievement_pins_subject_position_key","u"],["arbitration_cases","arbitration_cases_order_id_fkey","f"],["arbitration_cases","arbitration_cases_order_id_key","u"],["arbitration_cases","arbitration_cases_pkey","p"],["arbitration_decisions","arbitration_decisions_case_id_fkey","f"],["arbitration_decisions","arbitration_decisions_case_id_round_key","u"],["arbitration_decisions","arbitration_decisions_pkey","p"],["arbitration_evidence","arbitration_evidence_author_fkey","f"],["arbitration_evidence","arbitration_evidence_case_id_fkey","f"],["arbitration_evidence","arbitration_evidence_pkey","p"],["arbitration_policies","arbitration_policies_pkey","p"],["arbitration_votes","arbitration_votes_arbitrator_fkey","f"],["arbitration_votes","arbitration_votes_case_id_fkey","f"],["arbitration_votes","arbitration_votes_pkey","p"],["arbitrator_conflicts","arbitrator_conflicts_arbitrator_fkey","f"],["arbitrator_conflicts","arbitrator_conflicts_party_fkey","f"],["arbitrator_conflicts","arbitrator_conflicts_pkey","p"],["arbitrator_roles","arbitrator_roles_pkey","p"],["arbitrator_roles","arbitrator_roles_subject_fkey","f"],["audit","audit_digest_key","u"],["audit","audit_pkey","p"],["batches","batches_pkey","p"],["bounty_challenges","bounty_challenges_claimant_fkey","f"],["bounty_challenges","bounty_challenges_listing_id_fkey","f"],["bounty_challenges","bounty_challenges_pkey","p"],["bounty_challenges","bounty_challenges_verifier_version_check","c"],["bounty_claims","bounty_claims_challenge_id_fkey","f"],["bounty_claims","bounty_claims_challenge_id_key","u"],["bounty_claims","bounty_claims_claimant_fkey","f"],["bounty_claims","bounty_claims_listing_id_fkey","f"],["bounty_claims","bounty_claims_pkey","p"],["bounty_claims","bounty_claims_reward_minor_check","c"],["bounty_claims","bounty_claims_status_check","c"],["bounty_claims","bounty_claims_transaction_id_fkey","f"],["bounty_claims","bounty_claims_transaction_id_key","u"],["bounty_listings","bounty_listings_budget_minor_check","c"],["bounty_listings","bounty_listings_claim_limit_per_subject_check","c"],["bounty_listings","bounty_listings_escrow_account_fkey","f"],["bounty_listings","bounty_listings_escrow_subject_key","u"],["bounty_listings","bounty_listings_listing_id_fkey","f"],["bounty_listings","bounty_listings_max_claims_check","c"],["bounty_listings","bounty_listings_pkey","p"],["bounty_listings","bounty_listings_publisher_fkey","f"],["bounty_listings","bounty_listings_reward_minor_check","c"],["bounty_listings","bounty_listings_state_check","c"],["bounty_listings","bounty_listings_verifier_version_check","c"],["certificates","certificates_pkey","p"],["chunks","chunks_pkey","p"],["claims","claims_id_fkey","f"],["claims","claims_pkey","p"],["collaboration_leases","collaboration_leases_pkey","p"],["collaboration_leases","collaboration_leases_status_check","c"],["collaboration_leases","collaboration_leases_target_fkey","f"],["credentials","credentials_pkey","p"],["csrs","csrs_pkey","p"],["custodial_upgrades","custodial_upgrades_pkey","p"],["custodial_upgrades","custodial_upgrades_status_check","c"],["custodial_vault","custodial_vault_lifecycle_check","c"],["custodial_vault","custodial_vault_pkey","p"],["delivery_email_challenges","delivery_email_challenges_order_id_fkey","f"],["delivery_email_challenges","delivery_email_challenges_pkey","p"],["delivery_email_challenges","delivery_email_challenges_subject_fkey","f"],["delivery_email_endpoints","delivery_email_endpoints_pkey","p"],["delivery_email_endpoints","delivery_email_endpoints_subject_fkey","f"],["delivery_envelopes","delivery_envelopes_order_id_fkey","f"],["delivery_envelopes","delivery_envelopes_pkey","p"],["dm_archives","dm_archives_pair_fkey","f"],["dm_archives","dm_archives_pkey","p"],["dm_blocks","dm_blocks_pkey","p"],["dm_conversations","dm_conversations_check","c"],["dm_conversations","dm_conversations_conversation_kind_check","c"],["dm_conversations","dm_conversations_pkey","p"],["dm_conversations","dm_conversations_resource_id_fkey","f"],["dm_conversations","dm_conversations_resource_id_key","u"],["dm_conversations","dm_conversations_state_check","c"],["email_challenges","email_challenges_pkey","p"],["emails","emails_pkey","p"],["encryption_subkeys","encryption_subkeys_pkey","p"],["encryption_subkeys","encryption_subkeys_recipient_key","u"],["events","events_id_key","u"],["events","events_pkey","p"],["handoffs","handoffs_pkey","p"],["handoffs","handoffs_status_check","c"],["identities","identities_pkey","p"],["identity_keys","identity_keys_pkey","p"],["jobs","jobs_dedupe_key","u"],["jobs","jobs_pkey","p"],["ledger_accounts","ledger_accounts_check","c"],["ledger_accounts","ledger_accounts_kind_check","c"],["ledger_accounts","ledger_accounts_kind_source_id_key","u"],["ledger_accounts","ledger_accounts_pkey","p"],["ledger_accounts","ledger_accounts_subject_id_fkey","f"],["legacy_directive_versions","legacy_directive_versions_pkey","p"],["legacy_directive_versions","legacy_directive_versions_revision_id_fkey","f"],["legacy_directives","legacy_directives_pkey","p"],["legacy_directives","legacy_directives_resource_id_fkey","f"],["legacy_directives","legacy_directives_resource_id_key","u"],["legacy_states","legacy_states_pkey","p"],["legacy_states","legacy_states_state_check","c"],["memberships","memberships_pkey","p"],["messages","messages_pkey","p"],["money_accounts","money_accounts_currency_id_check","c"],["money_accounts","money_accounts_ledger_account_fkey","f"],["money_accounts","money_accounts_pkey","p"],["money_bank_roles","money_bank_roles_granted_by_check","c"],["money_bank_roles","money_bank_roles_pkey","p"],["money_bank_roles","money_bank_roles_status_check","c"],["money_bank_roles","money_bank_roles_subject_id_fkey","f"],["money_ledger","money_ledger_actor_request_id_entry_key_key","u"],["money_ledger","money_ledger_amount_minor_check","c"],["money_ledger","money_ledger_check","c"],["money_ledger","money_ledger_credit_ledger_account_fkey","f"],["money_ledger","money_ledger_currency_id_check","c"],["money_ledger","money_ledger_debit_ledger_account_fkey","f"],["money_ledger","money_ledger_id_key","u"],["money_ledger","money_ledger_kind_check","c"],["money_ledger","money_ledger_pkey","p"],["money_ledger","money_ledger_policy_version_check","c"],["money_purchases","money_purchases_entitlement_id_fkey","f"],["money_purchases","money_purchases_escrow_account_fkey","f"],["money_purchases","money_purchases_escrow_account_key","u"],["money_purchases","money_purchases_final_transaction_id_fkey","f"],["money_purchases","money_purchases_funding_transaction_id_fkey","f"],["money_purchases","money_purchases_pkey","p"],["money_purchases","money_purchases_quantity_check","c"],["money_purchases","money_purchases_state_check","c"],["money_purchases","money_purchases_subject_id_fkey","f"],["money_purchases","money_purchases_subject_id_request_id_key","u"],["money_purchases","money_purchases_total_minor_check","c"],["oauth_states","oauth_states_pkey","p"],["order_contracts","order_contracts_order_id_fkey","f"],["order_contracts","order_contracts_pkey","p"],["order_deadlines","order_deadlines_order_id_fkey","f"],["order_deadlines","order_deadlines_pkey","p"],["order_escrow_decisions","order_escrow_decisions_id_key","u"],["order_escrow_decisions","order_escrow_decisions_order_id_fkey","f"],["order_escrow_decisions","order_escrow_decisions_pkey","p"],["order_escrow_decisions","order_escrow_decisions_transaction_id_fkey","f"],["order_escrow_decisions","order_escrow_decisions_transaction_id_key","u"],["order_settlements","order_settlements_decision_id_key","u"],["order_settlements","order_settlements_order_id_fkey","f"],["order_settlements","order_settlements_pkey","p"],["order_transitions","order_transitions_order_id_fkey","f"],["order_transitions","order_transitions_pkey","p"],["personal_revision_proofs","personal_revision_proofs_pkey","p"],["personal_revision_proofs","personal_revision_proofs_revision_id_fkey","f"],["presence","presence_pkey","p"],["projections","projections_pkey","p"],["reactions","reactions_pkey","p"],["recovery_envelopes","recovery_envelopes_owner_ciphertext_resource_ciphertext_rev_key","u"],["recovery_envelopes","recovery_envelopes_pkey","p"],["recovery_policies","recovery_policies_pkey","p"],["relations","relations_revision_id_fkey","f"],["resource_entitlements","resource_entitlements_offer_id_fkey","f"],["resource_entitlements","resource_entitlements_pkey","p"],["resource_entitlements","resource_entitlements_quantity_check","c"],["resource_entitlements","resource_entitlements_redeem_transaction_id_fkey","f"],["resource_entitlements","resource_entitlements_redeem_transaction_id_key","u"],["resource_entitlements","resource_entitlements_subject_id_fkey","f"],["resource_entitlements","resource_entitlements_subject_id_purchase_request_id_key","u"],["resource_path_aliases","resource_path_aliases_parent_id_fkey","f"],["resource_path_aliases","resource_path_aliases_pkey","p"],["resource_path_aliases","resource_path_aliases_resource_id_fkey","f"],["resource_tags","resource_tags_pkey","p"],["resource_tags","resource_tags_resource_id_fkey","f"],["resources","resources_parent_fkey","f"],["resources","resources_parent_name_key","u"],["resources","resources_pkey","p"],["results","results_pkey","p"],["revisions","revisions_pkey","p"],["revisions","revisions_resource_id_fkey","f"],["schema_version","schema_version_pkey","p"],["server_offer_resources","server_offer_resources_listing_id_fkey","f"],["server_offer_resources","server_offer_resources_listing_id_key","u"],["server_offer_resources","server_offer_resources_offer_id_fkey","f"],["server_offer_resources","server_offer_resources_pkey","p"],["server_offers","server_offers_check","c"],["server_offers","server_offers_duration_seconds_check","c"],["server_offers","server_offers_min_quantity_check","c"],["server_offers","server_offers_pkey","p"],["server_offers","server_offers_price_minor_check","c"],["settings","settings_pkey","p"],["share_grants","share_grants_pkey","p"],["share_grants","share_grants_resource_id_fkey","f"],["share_grants_v2","share_grants_v2_allow_reshare_check","c"],["share_grants_v2","share_grants_v2_grantee_kind_check","c"],["share_grants_v2","share_grants_v2_parent_id_fkey","f"],["share_grants_v2","share_grants_v2_pkey","p"],["share_grants_v2","share_grants_v2_resource_id_fkey","f"],["share_links","share_links_pkey","p"],["share_links","share_links_resource_id_fkey","f"],["share_links","share_links_verifier_key","u"],["store_deliveries","store_deliveries_channel_check","c"],["store_deliveries","store_deliveries_order_id_fkey","f"],["store_deliveries","store_deliveries_order_id_key","u"],["store_deliveries","store_deliveries_pkey","p"],["store_deliveries","store_deliveries_recipient_subject_fkey","f"],["store_deliveries","store_deliveries_state_check","c"],["store_order_events","store_order_events_order_id_fkey","f"],["store_order_events","store_order_events_pkey","p"],["store_orders","store_orders_buyer_fkey","f"],["store_orders","store_orders_currency_id_check","c"],["store_orders","store_orders_escrow_account_fkey","f"],["store_orders","store_orders_escrow_subject_key","u"],["store_orders","store_orders_listing_id_fkey","f"],["store_orders","store_orders_payment_transaction_id_fkey","f"],["store_orders","store_orders_payment_transaction_id_key","u"],["store_orders","store_orders_pkey","p"],["store_orders","store_orders_quantity_check","c"],["store_orders","store_orders_seller_fkey","f"],["store_orders","store_orders_state_check","c"],["store_orders","store_orders_total_price_minor_check","c"],["store_orders","store_orders_unit_price_minor_check","c"],["store_packages","store_packages_pkey","p"],["store_packages","store_packages_revision_key","u"],["sync_checkpoints","sync_checkpoints_pkey","p"],["system_sources","system_sources_pkey","p"],["system_sources","system_sources_resource_id_fkey","f"],["system_sources","system_sources_revision_id_fkey","f"],["system_sources","system_sources_rule_id_key","u"],["system_sources","system_sources_source_path_key","u"],["token_deliveries","token_deliveries_pkey","p"],["topic_bans","topic_bans_pkey","p"],["topic_bans","topic_bans_status_check","c"],["topic_bans","topic_bans_topic_fkey","f"],["topic_event_projection","topic_event_projection_pkey","p"],["topic_event_projection","topic_event_projection_seq_fkey","f"],["topic_memberships","topic_memberships_pkey","p"],["topic_memberships","topic_memberships_role_check","c"],["topic_memberships","topic_memberships_status_check","c"],["topic_memberships","topic_memberships_topic_fkey","f"],["topic_settings","topic_settings_membership_policy_check","c"],["topic_settings","topic_settings_pkey","p"],["topic_settings","topic_settings_topic_fkey","f"],["transfers","transfers_pkey","p"],["watches","watches_pkey","p"],["webhook_endpoints","webhook_endpoints_enabled_check","c"],["webhook_endpoints","webhook_endpoints_pkey","p"]],"functions":[["msg_audit_append_only",""],["msg_escrow_decision_append_only",""],["msg_money_ledger_append_only",""],["msg_register_subject_ledger_account",""]],"indexes":[["achievement_ceremonies","achievement_ceremonies_pkey"],["achievement_ceremonies","achievement_ceremonies_subject"],["achievement_grants","achievement_grants_lookup"],["achievement_grants","achievement_grants_pkey"],["achievement_grants","achievement_grants_subject_achievement_id_spec_version_key"],["achievement_pins","achievement_pins_pkey"],["achievement_pins","achievement_pins_subject_position_key"],["arbitration_cases","arbitration_cases_order_id_key"],["arbitration_cases","arbitration_cases_pkey"],["arbitration_decisions","arbitration_decisions_case_id_round_key"],["arbitration_decisions","arbitration_decisions_pkey"],["arbitration_evidence","arbitration_evidence_pkey"],["arbitration_policies","arbitration_policies_pkey"],["arbitration_votes","arbitration_votes_pkey"],["arbitrator_conflicts","arbitrator_conflicts_pkey"],["arbitrator_roles","arbitrator_roles_pkey"],["audit","audit_digest_key"],["audit","audit_pkey"],["batches","batches_pkey"],["bounty_challenges","bounty_challenges_claimant"],["bounty_challenges","bounty_challenges_pkey"],["bounty_claims","bounty_claims_challenge_id_key"],["bounty_claims","bounty_claims_claimant"],["bounty_claims","bounty_claims_pkey"],["bounty_claims","bounty_claims_transaction_id_key"],["bounty_listings","bounty_listings_escrow_subject_key"],["bounty_listings","bounty_listings_pkey"],["bounty_listings","bounty_listings_publisher"],["certificates","certificates_pkey"],["chunks","chunks_pkey"],["claims","claims_pkey"],["claims","claims_subject"],["collaboration_leases","collaboration_leases_holder"],["collaboration_leases","collaboration_leases_pkey"],["credentials","credentials_pkey"],["credentials","credentials_subject"],["csrs","csrs_pkey"],["custodial_upgrades","custodial_upgrades_pkey"],["custodial_upgrades","custodial_upgrades_subject"],["custodial_vault","custodial_vault_pkey"],["delivery_email_challenges","delivery_email_challenges_pkey"],["delivery_email_endpoints","delivery_email_endpoints_pkey"],["delivery_envelopes","delivery_envelopes_pkey"],["dm_archives","dm_archives_pkey"],["dm_blocks","dm_blocks_pkey"],["dm_conversations","dm_conversations_participants"],["dm_conversations","dm_conversations_pkey"],["dm_conversations","dm_conversations_resource_id_key"],["email_challenges","email_challenges_pkey"],["emails","emails_pkey"],["encryption_subkeys","encryption_subkeys_pkey"],["encryption_subkeys","encryption_subkeys_primary"],["encryption_subkeys","encryption_subkeys_recipient_key"],["encryption_subkeys","encryption_subkeys_subject"],["events","events_id_key"],["events","events_pkey"],["handoffs","handoffs_from"],["handoffs","handoffs_pkey"],["handoffs","handoffs_to"],["identities","identities_pkey"],["identity_keys","identity_keys_pkey"],["identity_keys","identity_keys_primary"],["identity_keys","identity_keys_subject"],["jobs","jobs_dedupe_key"],["jobs","jobs_pkey"],["ledger_accounts","ledger_accounts_kind_source_id_key"],["ledger_accounts","ledger_accounts_pkey"],["legacy_directive_versions","legacy_directive_versions_pkey"],["legacy_directives","legacy_directives_pkey"],["legacy_directives","legacy_directives_resource_id_key"],["legacy_states","legacy_states_pkey"],["memberships","memberships_pkey"],["messages","messages_pkey"],["money_accounts","money_accounts_pkey"],["money_bank_roles","money_bank_roles_pkey"],["money_ledger","money_ledger_actor_request_id_entry_key_key"],["money_ledger","money_ledger_credit"],["money_ledger","money_ledger_debit"],["money_ledger","money_ledger_id_key"],["money_ledger","money_ledger_pkey"],["money_ledger","money_ledger_request_leg"],["money_purchases","money_purchases_escrow_account_key"],["money_purchases","money_purchases_pkey"],["money_purchases","money_purchases_subject_id_request_id_key"],["oauth_states","oauth_states_expiry"],["oauth_states","oauth_states_pkey"],["order_contracts","order_contracts_pkey"],["order_deadlines","order_deadlines_due"],["order_deadlines","order_deadlines_pkey"],["order_escrow_decisions","order_escrow_decisions_id_key"],["order_escrow_decisions","order_escrow_decisions_pkey"],["order_escrow_decisions","order_escrow_decisions_transaction_id_key"],["order_settlements","order_settlements_decision_id_key"],["order_settlements","order_settlements_pkey"],["order_transitions","order_transitions_pkey"],["personal_revision_proofs","personal_revision_proofs_pkey"],["presence","presence_pkey"],["projections","projections_pkey"],["reactions","reactions_pkey"],["recovery_envelopes","recovery_envelopes_owner"],["recovery_envelopes","recovery_envelopes_owner_ciphertext_resource_ciphertext_rev_key"],["recovery_envelopes","recovery_envelopes_pkey"],["recovery_policies","recovery_policies_pkey"],["relations","relations_target"],["resource_entitlements","resource_entitlements_pkey"],["resource_entitlements","resource_entitlements_redeem_transaction_id_key"],["resource_entitlements","resource_entitlements_subject_id_purchase_request_id_key"],["resource_path_aliases","resource_path_aliases_pkey"],["resource_tags","resource_tags_pkey"],["resource_tags","resource_tags_resource"],["resources","one_root"],["resources","resources_owner"],["resources","resources_parent"],["resources","resources_parent_name_key"],["resources","resources_pkey"],["resources","resources_time"],["resources","resources_type_state"],["results","results_pkey"],["revisions","revisions_pkey"],["revisions","revisions_resource"],["schema_version","schema_version_pkey"],["server_offer_resources","server_offer_resources_listing_id_key"],["server_offer_resources","server_offer_resources_pkey"],["server_offers","server_offers_pkey"],["settings","settings_pkey"],["share_grants","share_grants_active"],["share_grants","share_grants_grantee"],["share_grants","share_grants_pkey"],["share_grants_v2","share_grants_v2_parent"],["share_grants_v2","share_grants_v2_pkey"],["share_grants_v2","share_grants_v2_resource"],["share_links","share_links_pkey"],["share_links","share_links_resource"],["share_links","share_links_verifier_key"],["store_deliveries","store_deliveries_order_id_key"],["store_deliveries","store_deliveries_pkey"],["store_order_events","store_order_events_order"],["store_order_events","store_order_events_pkey"],["store_orders","store_orders_buyer"],["store_orders","store_orders_escrow_subject_key"],["store_orders","store_orders_listing"],["store_orders","store_orders_payment_transaction_id_key"],["store_orders","store_orders_pkey"],["store_orders","store_orders_seller"],["store_packages","store_packages_pkey"],["store_packages","store_packages_revision_key"],["sync_checkpoints","sync_checkpoints_pkey"],["sync_checkpoints","sync_checkpoints_subject"],["system_sources","system_sources_pkey"],["system_sources","system_sources_rule_id_key"],["system_sources","system_sources_source_path_key"],["token_deliveries","token_deliveries_pkey"],["token_deliveries","token_deliveries_subject"],["topic_bans","topic_bans_pkey"],["topic_event_projection","topic_event_projection_pkey"],["topic_event_projection","topic_event_projection_topic"],["topic_memberships","topic_memberships_pkey"],["topic_memberships","topic_memberships_subject"],["topic_settings","topic_settings_pkey"],["transfers","transfers_pkey"],["watches","watches_pkey"],["webhook_endpoints","webhook_endpoints_pkey"]],"sequences":[["audit_seq_seq","bigint"],["events_seq_seq","bigint"],["money_ledger_seq_seq","bigint"],["store_order_events_seq_seq","bigint"]],"tables":{"achievement_ceremonies":[["id","text",true,""],["subject","text",true,""],["state","text",true,""],["body","text",true,""]],"achievement_grants":[["id","text",true,""],["subject","text",true,""],["achievement_id","text",true,""],["spec_version","integer",true,""],["body","text",true,""]],"achievement_pins":[["subject","text",true,""],["grant_id","text",true,""],["position","integer",true,""]],"arbitration_cases":[["id","text",true,""],["order_id","text",true,""],["round","integer",true,""],["state","text",true,""],["deadline","text",true,""],["body","text",true,""]],"arbitration_decisions":[["id","text",true,""],["case_id","text",true,""],["round","integer",true,""],["body","text",true,""],["revoked","boolean",true,""]],"arbitration_evidence":[["id","text",true,""],["case_id","text",true,""],["author","text",true,""],["visibility","text",true,""],["body","text",true,""]],"arbitration_policies":[["id","text",true,""],["body","text",true,""],["digest","text",true,""]],"arbitration_votes":[["case_id","text",true,""],["round","integer",true,""],["arbitrator","text",true,""],["body","text",true,""]],"arbitrator_conflicts":[["arbitrator","text",true,""],["party","text",true,""]],"arbitrator_roles":[["subject","text",true,""],["epoch","text",true,""],["active","boolean",true,""]],"audit":[["seq","bigint",true,"d"],["digest","text",true,""],["previous","text",false,""],["body","text",true,""]],"batches":[["subject","text",true,""],["request_id","text",true,""],["digest","text",true,""]],"bounty_challenges":[["id","text",true,""],["listing_id","text",true,""],["claimant","text",true,""],["key_id","text",true,""],["nonce","text",true,""],["issued_at","text",true,""],["expires_at","text",true,""],["verifier_version","integer",true,""],["payload","text",true,""],["consumed_at","text",false,""]],"bounty_claims":[["id","text",true,""],["listing_id","text",true,""],["claimant","text",true,""],["challenge_id","text",true,""],["proof_digest","text",true,""],["status","text",true,""],["reward_minor","bigint",true,""],["transaction_id","text",true,""],["claimed_at","text",true,""]],"bounty_listings":[["listing_id","text",true,""],["publisher","text",true,""],["escrow_subject","text",true,""],["reward_minor","bigint",true,""],["budget_minor","bigint",true,""],["max_claims","integer",true,""],["claim_limit_per_subject","integer",true,""],["verifier_id","text",true,""],["verifier_version","integer",true,""],["eligibility","text",true,""],["state","text",true,""],["pause_reason","text",false,""],["expires_at","text",false,""],["created_at","text",true,""]],"certificates":[["id","text",true,""],["subject","text",true,""],["parent","text",false,""],["revoked","integer",true,""],["body","text",true,""]],"chunks":[["transfer_id","text",true,""],["offset","bigint",true,""],["length","bigint",true,""],["body","text",true,""]],"claims":[["id","text",true,""],["subject","text",true,""],["issued_at","text",true,""],["expires_at","text",false,""],["body","text",true,""]],"collaboration_leases":[["id","text",true,""],["holder","text",true,""],["target","text",true,""],["status","text",true,""],["generation","integer",true,""],["expires_at","text",true,""],["body","text",true,""]],"credentials":[["id","text",true,""],["subject","text",true,""],["body","text",true,""]],"csrs":[["id","text",true,""],["generation","integer",true,""],["state","text",true,""],["body","text",true,""],["state_body","text",true,""]],"custodial_upgrades":[["id","text",true,""],["subject","text",true,""],["credential_id","text",true,""],["status","text",true,""],["expires_at","text",true,""],["challenge","text",true,""],["ephemeral_nonce","text",false,""],["ephemeral_ciphertext","text",false,""],["body","text",true,""]],"custodial_vault":[["subject","text",true,""],["signing_key_id","text",true,""],["encryption_key_id","text",true,""],["signing_nonce","text",false,""],["signing_ciphertext","text",false,""],["age_nonce","text",false,""],["age_ciphertext","text",false,""],["status","text",true,""],["created_at","text",true,""],["destroyed_at","text",false,""]],"delivery_email_challenges":[["id","text",true,""],["order_id","text",true,""],["subject","text",true,""],["address","text",true,""],["verifier","text",true,""],["expires_at","text",true,""],["consumed","boolean",true,""]],"delivery_email_endpoints":[["id","text",true,""],["subject","text",true,""],["address","text",true,""],["verified_at","text",true,""],["active","boolean",true,""]],"delivery_envelopes":[["order_id","text",true,""],["body","text",true,""]],"dm_archives":[["subject","text",true,""],["pair","text",true,""]],"dm_blocks":[["blocker","text",true,""],["blocked","text",true,""]],"dm_conversations":[["pair","text",true,""],["resource_id","text",true,""],["participant_a","text",true,""],["participant_b","text",true,""],["initiator","text",true,""],["conversation_kind","text",true,""],["state","text",true,""],["created_at","text",true,""],["updated_at","text",true,""]],"email_challenges":[["subject","text",true,""],["digest","text",true,""],["expires","text",true,""]],"emails":[["subject","text",true,""],["generation","integer",true,""],["body","text",true,""]],"encryption_subkeys":[["key_id","text",true,""],["subject","text",true,""],["recipient","text",true,""],["public_key","text",true,""],["created_at","text",true,""],["retired_at","text",false,""],["is_primary","integer",true,""]],"events":[["seq","bigint",true,"d"],["id","text",true,""],["body","text",true,""]],"handoffs":[["id","text",true,""],["from_subject","text",true,""],["to_subject","text",true,""],["status","text",true,""],["generation","integer",true,""],["body","text",true,""]],"identities":[["id","text",true,""],["kind","text",true,""],["generation","integer",true,""],["body","text",true,""]],"identity_keys":[["key_id","text",true,""],["subject","text",true,""],["public_key","text",true,""],["created_at","text",true,""],["retired_at","text",false,""],["is_primary","integer",true,""]],"jobs":[["id","text",true,""],["dedupe","text",true,""],["kind","text",true,""],["state","text",true,""],["next_at","text",true,""],["body","text",true,""]],"ledger_accounts":[["id","text",true,""],["kind","text",true,""],["subject_id","text",false,""],["source_id","text",false,""]],"legacy_directive_versions":[["revision_id","text",true,""],["resource_id","text",true,""],["subject","text",true,""],["body","text",true,""]],"legacy_directives":[["subject","text",true,""],["resource_id","text",true,""],["created_at","text",true,""]],"legacy_states":[["subject","text",true,""],["state","text",true,""]],"memberships":[["org","text",true,""],["subject","text",true,""],["generation","integer",true,""],["body","text",true,""]],"messages":[["id","text",true,""],["sender","text",false,""],["recipient","text",false,""],["resource","text",false,""],["event_id","text",false,""],["body","text",true,""]],"money_accounts":[["subject_id","text",true,""],["currency_id","text",true,""]],"money_bank_roles":[["subject_id","text",true,""],["status","text",true,""],["granted_at","text",true,""],["granted_by","text",true,""],["revoked_at","text",false,""]],"money_ledger":[["seq","bigint",true,"d"],["id","text",true,""],["kind","text",true,""],["currency_id","text",true,""],["amount_minor","bigint",true,""],["debit_account","text",false,""],["credit_account","text",false,""],["actor","text",true,""],["request_id","text",true,""],["reference","text",false,""],["committed_at","text",true,""],["policy_version","integer",true,""],["policy_digest","text",true,""],["receipt","text",true,""],["entry_key","text",true,""]],"money_purchases":[["id","text",true,""],["subject_id","text",true,""],["request_id","text",true,""],["offer_snapshot","text",true,""],["quantity","bigint",true,""],["total_minor","bigint",true,""],["escrow_account","text",true,""],["state","text",true,""],["created_at","text",true,""],["expires_at","text",true,""],["funding_transaction_id","text",true,""],["final_transaction_id","text",false,""],["entitlement_id","text",false,""],["reason","text",false,""]],"oauth_states":[["id","text",true,""],["kind","text",true,""],["expires","text",true,""],["body","text",true,""]],"order_contracts":[["order_id","text",true,""],["body","text",true,""],["digest","text",true,""]],"order_deadlines":[["order_id","text",true,""],["expires_at","text",true,""]],"order_escrow_decisions":[["order_id","text",true,""],["id","text",true,""],["body","text",true,""],["signature","text",true,""],["source_proof","text",true,""],["transaction_id","text",true,""]],"order_settlements":[["order_id","text",true,""],["decision_id","text",false,""],["body","text",true,""]],"order_transitions":[["id","text",true,""],["order_id","text",true,""],["body","text",true,""]],"personal_revision_proofs":[["revision_id","text",true,""],["subject","text",true,""],["kind","text",true,""],["signature","text",true,""],["signed_envelope","text",true,""],["created_at","text",true,""]],"presence":[["subject","text",true,""],["expires_at","text",true,""],["body","text",true,""]],"projections":[["resource_id","text",true,""],["text","text",true,""]],"reactions":[["subject","text",true,""],["resource","text",true,""],["kind","text",true,""],["revision","text",true,""],["body","text",true,""]],"recovery_envelopes":[["id","text",true,""],["owner","text",true,""],["ciphertext_resource","text",true,""],["ciphertext_revision","text",true,""],["policy_version","integer",true,""],["encryption_key_id","text",true,""],["purpose","text",true,""],["created_at","text",true,""],["body","text",true,""]],"recovery_policies":[["subject","text",true,""],["version","integer",true,""],["encryption_key_id","text",true,""],["created_at","text",true,""],["body","text",true,""]],"relations":[["revision_id","text",true,""],["source_id","text",true,""],["type","text",true,""],["target_id","text",true,""],["target_revision","text",false,""],["body","text",true,""]],"resource_entitlements":[["id","text",true,""],["subject_id","text",true,""],["offer_id","text",true,""],["purchase_request_id","text",true,""],["quantity","bigint",true,""],["entitlement_kind","text",true,""],["granted_at","text",true,""],["expires_at","text",false,""],["redeem_transaction_id","text",false,""]],"resource_path_aliases":[["parent_id","text",true,""],["name","text",true,""],["resource_id","text",true,""]],"resource_tags":[["resource_id","text",true,""],["tag","text",true,""]],"resources":[["id","text",true,""],["type","text",true,""],["name","text",true,""],["parent","text",false,""],["owner","text",true,""],["grp","text",true,""],["mode","integer",true,""],["generation","integer",true,""],["revision","text",false,""],["state","text",true,""],["created_at","text",true,""],["modified_at","text",true,""],["body","text",true,""]],"results":[["subject","text",true,""],["request_id","text",true,""],["digest","text",true,""],["body","text",true,""]],"revisions":[["id","text",true,""],["resource_id","text",true,""],["created_at","text",true,""],["body","text",true,""]],"schema_version":[["version","integer",true,""]],"server_offer_resources":[["offer_id","text",true,""],["listing_id","text",true,""]],"server_offers":[["offer_id","text",true,""],["resource_kind","text",true,""],["unit","text",true,""],["price_minor","bigint",true,""],["min_quantity","bigint",true,""],["max_quantity","bigint",true,""],["entitlement_kind","text",true,""],["duration_seconds","bigint",false,""],["enabled","boolean",true,""],["price_revision","text",true,""]],"settings":[["key","text",true,""],["value","text",true,""]],"share_grants":[["id","text",true,""],["resource_id","text",true,""],["grantor","text",true,""],["grantee","text",true,""],["created_at","text",true,""],["expires_at","text",true,""],["revoked_at","text",false,""]],"share_grants_v2":[["id","text",true,""],["resource_id","text",true,""],["grantor","text",true,""],["grantee","text",true,""],["grantee_kind","text",true,""],["parent_id","text",false,""],["operations","text",true,""],["constraints","text",true,""],["allow_reshare","integer",true,""],["created_at","text",true,""],["expires_at","text",true,""],["revoked_at","text",false,""]],"share_links":[["id","text",true,""],["resource_id","text",true,""],["grantor","text",true,""],["credential_id","text",true,""],["verifier","text",true,""],["created_at","text",true,""],["expires_at","text",true,""],["revoked_at","text",false,""]],"store_deliveries":[["id","text",true,""],["order_id","text",true,""],["recipient_subject","text",true,""],["kind","text",true,""],["payload_refs","text",true,""],["manifest","text",true,""],["package_digest","text",false,""],["delivery_digest","text",true,""],["channel","text",true,""],["state","text",true,""],["prepared_at","text",true,""],["claimed_at","text",false,""],["receipt","text",false,""]],"store_order_events":[["seq","bigint",true,"d"],["order_id","text",true,""],["from_state","text",false,""],["to_state","text",true,""],["reason","text",true,""],["actor","text",true,""],["request_id","text",true,""],["occurred_at","text",true,""],["receipt_refs","text",true,""]],"store_orders":[["id","text",true,""],["buyer","text",true,""],["seller","text",true,""],["listing_id","text",true,""],["listing_revision","text",true,""],["package_id","text",false,""],["package_revision","text",false,""],["package_digest","text",false,""],["quantity","bigint",true,""],["unit_price_minor","bigint",true,""],["total_price_minor","bigint",true,""],["currency_id","text",true,""],["escrow_subject","text",true,""],["escrow_policy","text",true,""],["dispute_policy","text",true,""],["terms_digest","text",true,""],["delivery_target","text",true,""],["payment_intent_digest","text",true,""],["payment_transaction_id","text",false,""],["state","text",true,""],["created_at","text",true,""],["funded_at","text",false,""],["delivered_at","text",false,""],["settled_at","text",false,""],["receipt_refs","text",true,""],["delivery_mode","text",true,""],["deadline_at","text",false,""],["quote_digest","text",false,""]],"store_packages":[["id","text",true,""],["listing_id","text",true,""],["listing_revision","text",true,""],["seller","text",true,""],["revision","text",true,""],["kind","text",true,""],["manifest","text",true,""],["payload_refs","text",true,""],["digest","text",true,""],["total_size","bigint",true,""],["delivery_mode","text",true,""],["deposited_at","text",true,""]],"sync_checkpoints":[["id","text",true,""],["subject","text",true,""],["credential_id","text",true,""],["version","integer",true,""],["expires_at","text",true,""],["body","text",true,""]],"system_sources":[["resource_id","text",true,""],["source_path","text",true,""],["rule_id","text",true,""],["source_kind","text",true,""],["source_version","integer",true,""],["source_digest","text",true,""],["revision_id","text",true,""]],"token_deliveries":[["credential_id","text",true,""],["subject","text",true,""],["request_id","text",true,""],["request_digest","text",true,""],["recovery_verifier","text",true,""],["recovery_expires_at","text",true,""],["claimed_at","text",false,""],["consumed_at","text",false,""]],"topic_bans":[["topic","text",true,""],["subject","text",true,""],["actor","text",true,""],["created_at","text",true,""],["expires_at","text",false,""],["reason","text",false,""],["status","text",true,""]],"topic_event_projection":[["seq","bigint",true,""],["topic","text",true,""]],"topic_memberships":[["topic","text",true,""],["subject","text",true,""],["role","text",true,""],["status","text",true,""],["joined_at","text",false,""],["invited_by","text",false,""]],"topic_settings":[["topic","text",true,""],["membership_policy","text",true,""]],"transfers":[["id","text",true,""],["subject","text",true,""],["generation","integer",true,""],["body","text",true,""],["limits","text",true,""]],"watches":[["subject","text",true,""],["resource","text",true,""]],"webhook_endpoints":[["subject","text",true,""],["url","text",true,""],["nonce","text",true,""],["ciphertext","text",true,""],["enabled","integer",true,""],["generation","integer",true,""]]},"triggers":[["arbitration_policies","arbitration_policies_append_only"],["arbitration_votes","arbitration_votes_append_only"],["audit","audit_no_update"],["money_accounts","money_account_register_subject"],["money_ledger","money_ledger_no_update"],["order_contracts","order_contracts_append_only"],["order_escrow_decisions","escrow_decision_no_update"],["order_settlements","order_settlements_append_only"],["order_transitions","order_transitions_append_only"],["store_order_events","store_order_events_append_only"]]} diff --git a/src/msg/hosting_runtime.py b/src/msg/hosting_runtime.py index c8fa0503..1f31c9c9 100644 --- a/src/msg/hosting_runtime.py +++ b/src/msg/hosting_runtime.py @@ -210,6 +210,7 @@ async def load(self): ) self.authorizer = AuthorizationService(self.registry, self.certificates) self.authenticator.runtime_generation = self.runtime_generation + self.authenticator.oauth_config = self.settings.oauth self.authorizer.runtime_generation = self.runtime_generation self.require_ready(loading=True) self._loaded = True diff --git a/src/msg/oauth_config.py b/src/msg/oauth_config.py new file mode 100644 index 00000000..cbb39062 --- /dev/null +++ b/src/msg/oauth_config.py @@ -0,0 +1,93 @@ +"""Registered public OAuth clients and bounded login lifetimes.""" + +from dataclasses import dataclass +from urllib.parse import urlsplit + +from msg.core.errors import require + +SCOPES = frozenset({'openid', 'profile', 'offline_access', 'msg.read', 'msg.write'}) + + +@dataclass(frozen=True, slots=True) +class OAuthClient: + client_id: str + name: str + redirect_uris: tuple[str, ...] = () + scopes: frozenset[str] = SCOPES + + +@dataclass(frozen=True, slots=True) +class OAuthConfig: + enabled: bool = False + access_ttl: int = 900 + session_ttl: int = 2592000 + clients: tuple[OAuthClient, ...] = (OAuthClient('msg-cli', 'MSG CLI'),) + + +def load_oauth(data, service): + require( + set(data) <= {'enabled', 'access_ttl', 'session_ttl', 'clients'}, 'invalid_oauth_config' + ) + require(type(data.get('enabled', False)) is bool, 'invalid_oauth_config') + values = {} + for name, default, maximum in (('access_ttl', 900, 3600), ('session_ttl', 2592000, 7776000)): + value = data.get(name, default) + require(type(value) is int and 60 <= value <= maximum, 'invalid_oauth_config') + values[name] = value + clients = [OAuthClient('msg-cli', 'MSG CLI')] + raw = data.get('clients', []) + require(isinstance(raw, list) and len(raw) <= 100, 'invalid_oauth_config') + for item in raw: + require( + isinstance(item, dict) + and set(item) <= {'client_id', 'name', 'redirect_uris', 'scopes'}, + 'invalid_oauth_client', + ) + cid, name = item.get('client_id'), item.get('name') + require( + isinstance(cid, str) + and 1 <= len(cid) <= 100 + and all(c.isalnum() or c in '-_' for c in cid) + and isinstance(name, str) + and 1 <= len(name) <= 100, + 'invalid_oauth_client', + ) + redirects = item.get('redirect_uris', []) + scopes = item.get('scopes', sorted(SCOPES)) + require(isinstance(redirects, list) and len(redirects) <= 20, 'invalid_oauth_client') + require( + isinstance(scopes, list) + and all(isinstance(s, str) for s in scopes) + and set(scopes) <= SCOPES, + 'invalid_oauth_client', + ) + for uri in redirects: + require(isinstance(uri, str) and len(uri) <= 2000, 'invalid_redirect_uri') + try: + parsed = urlsplit(uri) + valid = ( + parsed.hostname + and not parsed.username + and not parsed.password + and not parsed.fragment + and not parsed.query + and ( + parsed.scheme == 'https' + or (parsed.scheme == 'http' and parsed.hostname in {'127.0.0.1', '::1'}) + ) + and not any(ord(c) <= 32 for c in uri) + and '\\' not in uri + ) + valid = valid and (parsed.port is None or parsed.port > 0) + except ValueError: + valid = False + require(valid, 'invalid_redirect_uri') + clients.append(OAuthClient(cid, name, tuple(redirects), frozenset(scopes))) + require(len({c.client_id for c in clients}) == len(clients), 'duplicate_oauth_client') + require( + not data.get('enabled', False) + or urlsplit(service).scheme == 'https' + or urlsplit(service).hostname in {'localhost', '127.0.0.1', '::1', 'testserver'}, + 'secure_channel_required', + ) + return OAuthConfig(data.get('enabled', False), clients=tuple(clients), **values) diff --git a/src/msg/plugins/identity.py b/src/msg/plugins/identity.py index cdc7ea59..16fea008 100644 --- a/src/msg/plugins/identity.py +++ b/src/msg/plugins/identity.py @@ -370,6 +370,9 @@ async def validate_ceiling(app, ctx, tx, grants): def install(app): op, finish = registration(app, 'identity') + from msg.plugins.oauth_identity import install as install_oauth + + install_oauth(app, op) @op( 'identity.register', @@ -2394,9 +2397,38 @@ async def key_revoke(ctx, request, tx): signature=True, version=2, ) + @op( + 'identity.token_create', + obj( + { + 'nonce': BYTES, + 'ceiling': GRANTS, + 'ttl': {'type': 'integer', 'minimum': 1, 'maximum': 86400}, + 'recovery_secret': BYTES, + 'previous_credential': IDENTIFIER, + }, + ('nonce', 'ttl', 'recovery_secret'), + ), + signature=True, + version=3, + ) async def token_create(ctx, request, tx): subject = await controlled_owner(app, ctx, request, tx) - ceiling = tuple(decode(CapabilityGrant, g) for g in request.arguments['ceiling']) + if request.contract_version == 3 and 'ceiling' not in request.arguments: + reads = frozenset( + f'{operation.name}@{operation.version}' + for operation in app.registry.operations() + if operation.effect == 'read' + and not operation.require_signature + and not operation.name.startswith(('identity.', 'root.', 'system.')) + ) + ceiling = tuple( + replace(g, operations=g.operations & reads) + for g in ctx.principal.ceiling + if g.operations & reads + ) + else: + ceiling = tuple(decode(CapabilityGrant, g) for g in request.arguments['ceiling']) await validate_ceiling(app, ctx, tx, ceiling) require(len(unb64(request.arguments['nonce'])) >= 24, 'invalid_bootstrap_nonce') credential = Credential( @@ -2408,9 +2440,43 @@ async def token_create(ctx, request, tx): not_before=ctx.now, expires_at=ctx.now + timedelta(seconds=request.arguments['ttl']), revoked_at=None, + source_credential_id=ctx.principal.credential_id + if request.contract_version >= 3 + else None, ) await tx.save_credential(credential, subject.auth_version) app.record_token_delivery(tx, request, credential, ctx.now) + if request.contract_version >= 3: + from msg.security.oauth import put + + put( + tx, + 'api:' + credential.id, + 'api', + credential.expires_at, + { + 'subject': subject.resource_id, + 'parent': ctx.principal.credential_id, + 'auth_version': subject.auth_version, + }, + ) + previous_id = request.arguments.get('previous_credential') + if previous_id is not None: + previous = await tx.credential(previous_id) + require( + previous.kind == 'token' + and previous.subject_id == subject.resource_id + and previous.revoked_at is None, + 'credential_owner_required', + ) + require( + tx.one('SELECT id FROM oauth_states WHERE id=?', ('api:' + previous.id,)) + is not None, + 'api_key_required', + ) + await tx.save_credential( + replace(previous, revoked_at=ctx.now), subject.auth_version + ) return HandlerOutput( data={ 'subject_id': subject.resource_id, @@ -2476,12 +2542,18 @@ async def token_recover(ctx, request, tx): not_before=ctx.now, expires_at=old.expires_at, revoked_at=None, + source_credential_id=old.source_credential_id, ) app.record_token_delivery( tx, request, credential, ctx.now, recovery_deadline=parse_time(row[2]) ) await tx.save_credential(credential, subject.auth_version) await tx.save_credential(replace(old, revoked_at=ctx.now), subject.auth_version) + api = tx.one('SELECT body FROM oauth_states WHERE id=?', ('api:' + old.id,)) + if api is not None: + from msg.security.oauth import put + + put(tx, 'api:' + credential.id, 'api', credential.expires_at, loads(api[0])) consumed = tx.execute( """UPDATE token_deliveries SET consumed_at=? WHERE credential_id=? AND consumed_at IS NULL""", diff --git a/src/msg/plugins/oauth_identity.py b/src/msg/plugins/oauth_identity.py new file mode 100644 index 00000000..b6fc08aa --- /dev/null +++ b/src/msg/plugins/oauth_identity.py @@ -0,0 +1,39 @@ +"""OAuth approval requires the existing identity proof, never a device code alone.""" + +from msg.core.models import HandlerOutput +from msg.plugins.schemas import STRING, obj +from msg.security.oauth import OAuthService, client_for, get, state_id + + +def install(app, op): + @op('identity.oauth_request', obj({'user_code': STRING}, ('user_code',)), effect='read') + async def inspect(ctx, request, tx): + oauth = OAuthService(app) + oauth.fence(tx) + code = request.arguments['user_code'].upper().replace('-', '') + _, body = get(tx, state_id('user', code), ctx.now) + client = client_for(oauth.config, body['client_id']) + return HandlerOutput( + data={ + 'client_id': client.client_id, + 'client_name': client.name, + 'scopes': body['scopes'], + 'kind': body['kind'], + 'status': body['status'], + } + ) + + @op( + 'identity.oauth_approve', + obj( + {'user_code': STRING, 'decision': {'enum': ['approve', 'deny']}}, + ('user_code', 'decision'), + ), + ) + async def approve(ctx, request, tx): + oauth = OAuthService(app) + return HandlerOutput( + data=await oauth.approve( + tx, ctx.principal, request.arguments['user_code'], request.arguments['decision'] + ) + ) diff --git a/src/msg/security/authentication.py b/src/msg/security/authentication.py index 3bb32587..df459a56 100644 --- a/src/msg/security/authentication.py +++ b/src/msg/security/authentication.py @@ -16,6 +16,7 @@ from msg.security.token_delivery import recovery_verifier CUSTODIAL_SIGNED_WRITES = frozenset({ + 'identity.oauth_approve', 'content.post_create', 'content.post_edit', 'content.file_put', @@ -52,6 +53,7 @@ def __init__(self, registry, certificates, service, clock, primary_ceiling, temp ) self.primary_ceiling, self.temporary_ceiling = primary_ceiling, temporary_ceiling self.runtime_generation = RuntimeGeneration() + self.oauth_config = None async def authenticate(self, request, session, *, entry): self.runtime_generation.require_current(session) @@ -110,6 +112,9 @@ async def authenticate(self, request, session, *, entry): if request.operation == 'identity.token_recover': require(proof is None and request.subject is not None, 'invalid_recovery_proof') old = await session.credential(request.arguments['credential_id']) + from msg.security.oauth import require_binding + + await require_binding(session, old, now, self.oauth_config) require( old.kind == 'token' and old.subject_id == request.subject, 'recovery_unavailable' ) @@ -186,6 +191,9 @@ async def authenticate(self, request, session, *, entry): ) method = 'token' ids = () + from msg.security.oauth import require_binding + + await require_binding(session, credential, now, self.oauth_config) else: raise Failure('invalid_proof') if credential.revoked_at is not None: diff --git a/src/msg/security/capabilities.py b/src/msg/security/capabilities.py index 791a5b51..8c994021 100644 --- a/src/msg/security/capabilities.py +++ b/src/msg/security/capabilities.py @@ -18,6 +18,8 @@ 'identity.upgrade', 'identity.token_rotate', 'identity.token_create', + 'identity.oauth_request', + 'identity.oauth_approve', 'identity.token_recover', 'identity.upgrade_result', 'identity.key_add', @@ -95,6 +97,8 @@ } EXCLUDED_BASE = {'content.purge', 'content.chown', 'identity.recover'} TEMPORARY_OPERATIONS = frozenset({ + 'identity.oauth_request', + 'identity.oauth_approve', 'identity.temporary', 'identity.custodial_create', 'identity.upgrade', diff --git a/src/msg/security/oauth.py b/src/msg/security/oauth.py new file mode 100644 index 00000000..273d7cd7 --- /dev/null +++ b/src/msg/security/oauth.py @@ -0,0 +1,504 @@ +"""OAuth grants share MSG credentials, ceilings and current authority fences. + +Only digests of random authorization codes, refresh tokens and cookies persist. +Token responses never enter operation results, receipts, audit bodies or URLs. +The relational writer fence serializes grant consumption and refresh rotation. +""" + +import hashlib +import hmac +import secrets +from dataclasses import replace +from datetime import timedelta +from uuid import uuid4 + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + +from msg.core.codec import b64, canonical, loads, parse_time, wire +from msg.core.errors import Failure, require +from msg.core.models import Credential +from msg.security.quarantine import require_live_authority + +DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code' +CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + + +def secret(): + return secrets.token_urlsafe(32) + + +def state_id(kind, value): + return kind + ':' + hashlib.sha256(value.encode()).hexdigest() + + +def put(tx, id, kind, expires, body): + tx.execute( + 'INSERT INTO oauth_states VALUES (?,?,?,?) ON CONFLICT(id) DO UPDATE ' + 'SET expires=excluded.expires,body=excluded.body', + (id, kind, wire(expires), canonical(body).decode()), + write=True, + ) + + +def get(tx, id, now, *, expired=False): + row = tx.one('SELECT expires,body FROM oauth_states WHERE id=?', (id,)) + require(row is not None and (expired or parse_time(row[0]) > now), 'invalid_grant') + return parse_time(row[0]), loads(row[1]) + + +def save(tx, id, body): + tx.execute( + 'UPDATE oauth_states SET body=? WHERE id=?', (canonical(body).decode(), id), write=True + ) + + +def client_for(config, client_id): + require(config is not None and config.enabled, 'oauth_disabled') + client = next((c for c in config.clients if c.client_id == client_id), None) + require(client is not None, 'invalid_client') + return client + + +def scopes_for(client, value): + require(isinstance(value, str) and len(value) <= 200, 'invalid_scope') + scopes = frozenset(value.split()) + require(bool(scopes) and scopes <= client.scopes, 'invalid_scope') + return scopes + + +async def require_source(tx, body, now): + parent = await tx.credential(body['parent']) + subject = await tx.subject(body['subject']) + require( + parent.subject_id == subject.resource_id + and not subject.local_only + and subject.auth_version == body['auth_version'] + and parent.revoked_at is None + and parent.not_before <= now + and (parent.expires_at is None or parent.expires_at > now), + 'invalid_grant', + ) + if body.get('custodial'): + row = tx.one( + 'SELECT signing_key_id,status FROM custodial_vault WHERE subject=?', + (subject.resource_id,), + ) + require(subject.kind == 'custodial' and row == (parent.id, 'active'), 'invalid_grant') + if body.get('session'): + _, session = get(tx, body['session'], now) + require(not session.get('revoked'), 'invalid_grant') + await require_source(tx, session, now) + return subject + + +async def require_binding(tx, credential, now, config): + api = tx.one('SELECT body FROM oauth_states WHERE id=?', ('api:' + credential.id,)) + if api is not None: + source = loads(api[0]) + require(credential.source_credential_id == source['parent'], 'invalid_grant') + await require_source(tx, source, now) + row = tx.one('SELECT body FROM oauth_states WHERE id=?', ('access:' + credential.id,)) + if row is None: + require( + not credential.id.startswith('t_oauth_') + and (credential.source_credential_id is None or api is not None), + 'invalid_grant', + ) + return + binding = loads(row[0]) + _, family = get(tx, binding['family'], now) + require(credential.source_credential_id == family['parent'], 'invalid_grant') + client = client_for(config, family['client_id']) + require(set(family['scopes']) <= client.scopes and not family.get('revoked'), 'invalid_grant') + await require_source(tx, family, now) + + +class OAuthService: + def __init__(self, app): + self.app = app + + @property + def config(self): + return self.app.settings.oauth + + def fence(self, tx): + require(self.config.enabled, 'oauth_disabled') + self.app.runtime_generation.require_current(tx) + require_live_authority(tx) + require(not self.app.executor.recovery_drill_active(), 'recovery_quarantined') + + def rate(self, tx, address): + now = self.app.clock() + self.fence(tx) + tx.execute('DELETE FROM oauth_states WHERE expires<=?', (wire(now),), write=True) + id = state_id('rate', address or 'unknown') + row = tx.one('SELECT body,expires FROM oauth_states WHERE id=?', (id,)) + count = loads(row[0])['count'] if row else 0 + require(count < 120, 'slow_down') + require(tx.one('SELECT COUNT(*) FROM oauth_states')[0] < 100000, 'temporarily_unavailable') + put( + tx, + id, + 'rate', + parse_time(row[1]) if row else now + timedelta(minutes=10), + {'count': count + 1}, + ) + + def pending( + self, + tx, + *, + kind, + client_id='msg-cli', + scope='openid profile msg.read msg.write offline_access', + ): + now = self.app.clock() + client = client_for(self.config, client_id) + scopes = scopes_for(client, scope) + code = ''.join(secrets.choice(CODE_ALPHABET) for _ in range(8)) + value = secret() + id = state_id('user', code) + expiry = now + timedelta(minutes=10) + body = { + 'kind': kind, + 'client_id': client_id, + 'scopes': sorted(scopes), + 'status': 'pending', + 'poll': state_id('poll', value), + 'interval': 5, + 'next_poll': wire(now), + } + put(tx, id, 'pending', expiry, body) + put(tx, body['poll'], 'poll', expiry, {'user': id}) + return value, code, expiry + + async def source(self, tx, principal): + require( + principal.actor == principal.subject and principal.subject is not None, + 'identity_owner_required', + ) + subject = await tx.subject(principal.subject) + require( + subject.kind in {'registered', 'custodial'} and not subject.local_only, + 'registered_identity_required', + ) + require( + principal.method == 'signature' + or (subject.kind == 'custodial' and principal.method == 'token'), + 'signature_required', + ) + parent = principal.credential_id + if subject.kind == 'custodial': + row = tx.one( + 'SELECT signing_key_id,status FROM custodial_vault WHERE subject=?', + (subject.resource_id,), + ) + require(row is not None and row[1] == 'active', 'custodial_vault_unavailable') + parent = row[0] + return { + 'subject': subject.resource_id, + 'parent': parent, + 'auth_version': subject.auth_version, + 'custodial': subject.kind == 'custodial', + 'ceiling': wire(principal.ceiling), + 'auth_time': int(self.app.clock().timestamp()), + } + + async def approve(self, tx, principal, code, decision): + self.fence(tx) + code = code.upper().replace('-', '') + require(len(code) == 8 and all(c in CODE_ALPHABET for c in code), 'invalid_grant') + id = state_id('user', code) + _, body = get(tx, id, self.app.clock()) + require(body['status'] == 'pending', 'invalid_grant') + client_for(self.config, body['client_id']) + source = await self.source(tx, principal) + body.update(source if decision == 'approve' else {}) + body['status'] = 'approved' if decision == 'approve' else 'denied' + save(tx, id, body) + return { + 'client_id': body['client_id'], + 'scopes': body['scopes'], + 'kind': body['kind'], + 'status': body['status'], + } + + async def pending_result(self, tx, value, client_id, kind): + now = self.app.clock() + _, locator = get(tx, state_id('poll', value), now) + expiry, body = get(tx, locator['user'], now) + require(body['client_id'] == client_id and body['kind'] == kind, 'invalid_grant') + require(body['status'] != 'consumed', 'invalid_grant') + if parse_time(body['next_poll']) > now: + body['interval'] += 5 + body['next_poll'] = wire(now + timedelta(seconds=body['interval'])) + save(tx, locator['user'], body) + return None, 'slow_down' + body['next_poll'] = wire(now + timedelta(seconds=body['interval'])) + save(tx, locator['user'], body) + if body['status'] == 'pending': + return None, 'authorization_pending' + if body['status'] == 'denied': + return None, 'access_denied' + await require_source(tx, body, now) + body['status'] = 'consumed' + save(tx, locator['user'], body) + if kind == 'login': + cookie = secret() + session_expiry = now + timedelta(seconds=self.config.session_ttl) + parent = await tx.credential(body['parent']) + if parent.expires_at is not None: + session_expiry = min(session_expiry, parent.expires_at) + put(tx, state_id('session', cookie), 'session', session_expiry, body) + return {'cookie': cookie, 'expires': session_expiry}, None + return await self.tokens(tx, body), None + + async def session(self, tx, cookie): + self.fence(tx) + id = state_id('session', cookie) + expiry, body = get(tx, id, self.app.clock()) + require(not body.get('revoked'), 'invalid_grant') + await require_source(tx, body, self.app.clock()) + source = { + name: body[name] + for name in ('subject', 'parent', 'auth_version', 'custodial', 'ceiling', 'auth_time') + } + return expiry, dict(source, session=id) + + def authorization(self, args): + require( + set(args) + <= { + 'response_type', + 'client_id', + 'redirect_uri', + 'scope', + 'state', + 'code_challenge', + 'code_challenge_method', + 'nonce', + }, + 'invalid_request', + ) + client = client_for(self.config, args.get('client_id')) + require(args.get('redirect_uri') in client.redirect_uris, 'invalid_redirect_uri') + require(args.get('response_type') == 'code', 'unsupported_response_type') + require(args.get('code_challenge_method') == 'S256', 'invalid_request') + challenge = args.get('code_challenge', '') + require( + len(challenge) == 43 and all(c.isalnum() or c in '-_' for c in challenge), + 'invalid_request', + ) + require( + isinstance(args.get('state'), str) and 16 <= len(args['state']) <= 512, + 'invalid_request', + ) + require('nonce' not in args or 16 <= len(args['nonce']) <= 512, 'invalid_request') + scopes_for(client, args.get('scope', 'openid profile')) + return client + + async def authorize(self, tx, cookie, args): + self.authorization(args) + expiry, source = await self.session(tx, cookie) + value = secret() + body = dict( + source, + client_id=args['client_id'], + scopes=sorted(args['scope'].split()), + redirect_uri=args['redirect_uri'], + challenge=args['code_challenge'], + nonce=args.get('nonce'), + ) + put( + tx, + state_id('code', value), + 'code', + min(expiry, self.app.clock() + timedelta(seconds=60)), + body, + ) + return value + + async def exchange(self, tx, args): + client_for(self.config, args.get('client_id')) + now = self.app.clock() + grant = args.get('grant_type') + if grant == DEVICE_GRANT: + return await self.pending_result( + tx, args.get('device_code', ''), args['client_id'], 'device' + ) + if grant == 'authorization_code': + id = state_id('code', args.get('code', '')) + _, body = get(tx, id, now) + if body.get('consumed') and body['client_id'] == args['client_id']: + _, family = get(tx, body['family'], now) + family['revoked'] = True + save(tx, body['family'], family) + return None, 'invalid_grant' + verifier = args.get('code_verifier', '') + require( + 43 <= len(verifier) <= 128 and all(c.isalnum() or c in '-._~' for c in verifier), + 'invalid_grant', + ) + require( + not body.get('consumed') + and body['client_id'] == args['client_id'] + and body['redirect_uri'] == args.get('redirect_uri') + and hmac.compare_digest( + body['challenge'], b64(hashlib.sha256(verifier.encode()).digest()) + ), + 'invalid_grant', + ) + await require_source(tx, body, now) + body['consumed'] = True + body['family'] = 'family:' + uuid4().hex + save(tx, id, body) + expiry = now + timedelta(seconds=self.config.session_ttl) + if body.get('session'): + expiry = min(expiry, get(tx, body['session'], now)[0]) + parent = await tx.credential(body['parent']) + if parent.expires_at is not None: + expiry = min(expiry, parent.expires_at) + put(tx, body['family'], 'family', expiry, body) + return await self.tokens(tx, body, family_id=body['family'], expiry=expiry), None + if grant == 'refresh_token': + id = state_id('refresh', args.get('refresh_token', '')) + _, old = get(tx, id, now) + expiry, family = get(tx, old['family'], now) + require(family['client_id'] == args['client_id'], 'invalid_grant') + if old.get('used'): + family['revoked'] = True + save(tx, old['family'], family) + return None, 'invalid_grant' + require(not family.get('revoked'), 'invalid_grant') + client = client_for(self.config, family['client_id']) + require(set(family['scopes']) <= client.scopes, 'invalid_scope') + await require_source(tx, family, now) + old['used'] = True + save(tx, id, old) + # A refresh does not extend the family's absolute lifetime. + return await self.tokens(tx, family, family_id=old['family'], expiry=expiry), None + return None, 'unsupported_grant_type' + + async def tokens(self, tx, source, *, family_id=None, expiry=None): + from msg.core.codec import decode + from msg.core.models import CapabilityGrant + + now = self.app.clock() + subject = await require_source(tx, source, now) + if family_id is None: + family_id = 'family:' + uuid4().hex + expiry = now + timedelta(seconds=self.config.session_ttl) + parent = await tx.credential(source['parent']) + if parent.expires_at is not None: + expiry = min(expiry, parent.expires_at) + if source.get('session'): + expiry = min(expiry, get(tx, source['session'], now)[0]) + put(tx, family_id, 'family', expiry, source) + credential_id, value = 't_oauth_' + uuid4().hex, secrets.token_bytes(32) + operations = { + f'{op.name}@{op.version}' + for op in self.app.registry.operations() + if not op.require_signature + and not op.name.startswith(('identity.', 'root.', 'system.')) + and ( + ('msg.read' in source['scopes'] and op.effect == 'read') + or ('msg.write' in source['scopes'] and op.effect != 'read') + ) + } + ceiling = tuple( + replace(g, operations=g.operations & operations) + for raw in source['ceiling'] + if (g := decode(CapabilityGrant, raw)).operations & operations + ) + access_expiry = min(expiry, now + timedelta(seconds=self.config.access_ttl)) + credential = Credential( + id=credential_id, + subject_id=subject.resource_id, + kind='token', + verifier=hashlib.sha256(value).digest(), + ceiling=ceiling, + not_before=now, + expires_at=access_expiry, + revoked_at=None, + source_credential_id=source['parent'], + ) + await tx.save_credential(credential, subject.auth_version) + put(tx, 'access:' + credential.id, 'access', access_expiry, {'family': family_id}) + result = { + 'access_token': credential.id + '.' + b64(value), + 'token_type': 'Bearer', + 'expires_in': int((access_expiry - now).total_seconds()), + 'subject_id': subject.resource_id, + 'scope': ' '.join(source['scopes']), + } + if 'offline_access' in source['scopes']: + refresh = secret() + put( + tx, + state_id('refresh', refresh), + 'refresh', + expiry, + {'family': family_id, 'used': False}, + ) + result.update(refresh_token=refresh, refresh_expires_at=wire(expiry)) + if 'openid' in source['scopes']: + result['id_token'] = self.id_token(source, access_expiry) + return result + + def signing_key(self): + return Ed25519PrivateKey.from_private_bytes( + hmac.digest(self.app._token_secret, b'oauth-oidc-signing-v1', 'sha256') + ) + + def jwk(self): + public = self.signing_key().public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + return { + 'kty': 'OKP', + 'crv': 'Ed25519', + 'use': 'sig', + 'alg': 'EdDSA', + 'kid': hashlib.sha256(public).hexdigest()[:32], + 'x': b64(public), + } + + def id_token(self, source, expiry): + payload = { + 'iss': self.app.settings.service_url, + 'sub': source['subject'], + 'aud': source['client_id'], + 'iat': int(self.app.clock().timestamp()), + 'exp': int(expiry.timestamp()), + 'auth_time': source['auth_time'], + } + if source.get('nonce'): + payload['nonce'] = source['nonce'] + head = b64(canonical({'alg': 'EdDSA', 'typ': 'JWT', 'kid': self.jwk()['kid']})) + body = b64(canonical(payload)) + value = (head + '.' + body).encode() + return value.decode() + '.' + b64(self.signing_key().sign(value)) + + async def bearer(self, tx, value): + from msg.core.codec import unb64 + + id, sep, token = value.partition('.') + require(sep and len(value) <= 256, 'invalid_token') + credential = await tx.credential(id) + now = self.app.clock() + require( + credential.kind == 'token' + and credential.revoked_at is None + and credential.not_before <= now + and credential.expires_at > now + and hmac.compare_digest( + credential.verifier, hashlib.sha256(unb64(token, limit=32)).digest() + ), + 'invalid_token', + ) + try: + await require_binding(tx, credential, now, self.config) + except Failure as exc: + if exc.code in {'recovery_runtime_stale', 'recovery_quarantined'}: + raise + raise Failure('invalid_token') from None + return credential diff --git a/src/msg/storage/ledger_migration.py b/src/msg/storage/ledger_migration.py index 96a5e4a8..fc624c61 100644 --- a/src/msg/storage/ledger_migration.py +++ b/src/msg/storage/ledger_migration.py @@ -291,6 +291,7 @@ def _install_subject_account_trigger(conn): 'memberships': ('organization_id', 'subject_id', 'invited_by'), 'emails': ('subject_id',), 'credentials': ('subject_id', 'ceiling.*.scope.resource_id'), + 'oauth_states': ('subject', 'ceiling.*.scope.resource_id'), 'certificates': ( 'resource_id', 'subject_id', diff --git a/src/msg/storage/postgres.py b/src/msg/storage/postgres.py index 3969ae69..0ac2a536 100644 --- a/src/msg/storage/postgres.py +++ b/src/msg/storage/postgres.py @@ -65,6 +65,10 @@ CREATE TABLE IF NOT EXISTS emails (subject TEXT PRIMARY KEY, generation INTEGER NOT NULL, body TEXT NOT NULL); CREATE TABLE IF NOT EXISTS credentials (id TEXT PRIMARY KEY, subject TEXT NOT NULL, body TEXT NOT NULL); CREATE INDEX IF NOT EXISTS credentials_subject ON credentials(subject); +CREATE TABLE IF NOT EXISTS oauth_states ( + id TEXT PRIMARY KEY, kind TEXT NOT NULL, expires TEXT NOT NULL, body TEXT NOT NULL); +CREATE INDEX IF NOT EXISTS oauth_states_expiry ON oauth_states(expires); + CREATE TABLE IF NOT EXISTS token_deliveries ( credential_id TEXT PRIMARY KEY, subject TEXT NOT NULL, request_id TEXT NOT NULL, request_digest TEXT NOT NULL, recovery_verifier TEXT NOT NULL, diff --git a/src/msg/storage/read_only.py b/src/msg/storage/read_only.py index 2efb7868..c0e70fe2 100644 --- a/src/msg/storage/read_only.py +++ b/src/msg/storage/read_only.py @@ -20,6 +20,7 @@ 'identities', 'memberships', 'credentials', + 'oauth_states', 'certificates', 'settings', 'share_grants', diff --git a/src/msg/storage/session.py b/src/msg/storage/session.py index 5e2cba46..e46b42b4 100644 --- a/src/msg/storage/session.py +++ b/src/msg/storage/session.py @@ -449,7 +449,8 @@ async def save_credential(self, credential, expected_auth_version): require( previous.subject_id == credential.subject_id and previous.kind == credential.kind - and previous.verifier == credential.verifier, + and previous.verifier == credential.verifier + and previous.source_credential_id == credential.source_credential_id, 'credential_identity_immutable', ) self.execute( diff --git a/src/msg/storage/sqlite.py b/src/msg/storage/sqlite.py index b456917f..508f69a0 100644 --- a/src/msg/storage/sqlite.py +++ b/src/msg/storage/sqlite.py @@ -56,6 +56,10 @@ CREATE TABLE IF NOT EXISTS emails (subject TEXT PRIMARY KEY, generation INTEGER NOT NULL, body TEXT NOT NULL); CREATE TABLE IF NOT EXISTS credentials (id TEXT PRIMARY KEY, subject TEXT NOT NULL, body TEXT NOT NULL); CREATE INDEX IF NOT EXISTS credentials_subject ON credentials(subject); +CREATE TABLE IF NOT EXISTS oauth_states ( + id TEXT PRIMARY KEY, kind TEXT NOT NULL, expires TEXT NOT NULL, body TEXT NOT NULL); +CREATE INDEX IF NOT EXISTS oauth_states_expiry ON oauth_states(expires); + CREATE TABLE IF NOT EXISTS token_deliveries ( credential_id TEXT PRIMARY KEY, subject TEXT NOT NULL, request_id TEXT NOT NULL, request_digest TEXT NOT NULL, recovery_verifier TEXT NOT NULL, diff --git a/src/msg/transports/client.py b/src/msg/transports/client.py index ea95544d..32846c43 100644 --- a/src/msg/transports/client.py +++ b/src/msg/transports/client.py @@ -79,7 +79,11 @@ async def _json(self, method, path, *, body=None, headers=None, maximum=None): require(isinstance(value, dict), 'invalid_server_response') if response.status_code >= 400 and 'status' not in value: error = value.get('error') - code = error.get('code') if isinstance(error, dict) else None + code = ( + error + if isinstance(error, str) + else (error.get('code') if isinstance(error, dict) else None) + ) raise Failure( safe_error_code(code, 'transport_error'), retryable=response.status_code in {502, 503, 504}, diff --git a/src/msg/transports/http.py b/src/msg/transports/http.py index bb8fc133..f7852633 100644 --- a/src/msg/transports/http.py +++ b/src/msg/transports/http.py @@ -124,5 +124,8 @@ async def __call__(self, scope, receive, send): def create_app(service): app = create_router(service) + from msg.transports.oauth_http import OAuthBoundary + + app.add_middleware(OAuthBoundary, service=service) app.add_middleware(PassiveGetBoundary, service=service) return app diff --git a/src/msg/transports/oauth_http.py b/src/msg/transports/oauth_http.py new file mode 100644 index 00000000..886e6469 --- /dev/null +++ b/src/msg/transports/oauth_http.py @@ -0,0 +1,505 @@ +"""Browser consent, persistent HttpOnly sessions and standard OAuth endpoints.""" + +import hashlib +import hmac +import os +from dataclasses import replace +from datetime import timedelta +from html import escape +from urllib.parse import parse_qsl, urlencode, urlsplit + +from starlette.requests import Request +from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse + +from msg.core.codec import b64, canonical, loads +from msg.core.errors import Failure, require +from msg.core.models import TokenProof +from msg.core.requests import request_for +from msg.security.oauth import DEVICE_GRANT, OAuthService, get, save, secret, state_id +from msg.transports.http_common import body_bytes +from msg.transports.packet import decode_packet +from msg.transports.url_safety import require_matching_host, require_safe_request_target + +HEADERS = { + 'Cache-Control': 'no-store', + 'Pragma': 'no-cache', + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; " + "script-src 'nonce-msg'; connect-src 'self'; form-action 'self'; " + "frame-ancestors 'none'; base-uri 'none'", +} + + +def json(value, status=200): + return JSONResponse(value, status_code=status, headers=HEADERS) + + +def page(title, body): + return HTMLResponse( + '' + '' + '' + escape(title) + '

' + + escape(title) + + '

' + + body + + '
', + headers=HEADERS, + ) + + +def fields(items): + return ''.join( + '' + for k, v in items.items() + ) + + +def csrf(cookie): + return b64(hmac.digest(cookie.encode(), b'msg-oauth-browser-csrf-v1', 'sha256')) + + +def unique(pairs): + require(len({k for k, _ in pairs}) == len(pairs), 'invalid_request') + return dict(pairs) + + +async def form(request): + raw = await body_bytes(request, 16384) + content = request.headers.get('content-type', '').split(';')[0] + if content == 'application/json': + value = loads(raw) + require(isinstance(value, dict), 'invalid_request') + else: + require(content == 'application/x-www-form-urlencoded', 'invalid_request') + try: + value = unique(parse_qsl(raw.decode(), keep_blank_values=True, max_num_fields=20)) + except UnicodeError, ValueError: + raise Failure('invalid_request') from None + require( + all(isinstance(k, str) and isinstance(v, str) and len(v) <= 4096 for k, v in value.items()), + 'invalid_request', + ) + return value + + +class OAuthBoundary: + def __init__(self, app, *, service): + self.app, self.service = app, service + self.oauth = OAuthService(service) + self.secure = urlsplit(service.settings.service_url).scheme == 'https' + self.session_cookie = '__Host-msg_session' if self.secure else 'msg_session' + self.login_cookie = '__Host-msg_login' if self.secure else 'msg_login' + + def cookie(self, response, name, value, ttl): + response.set_cookie( + name, value, max_age=ttl, secure=self.secure, httponly=True, samesite='lax', path='/' + ) + + def require_csrf(self, request, args, name): + value = request.cookies.get(name, '') + require( + value + and request.headers.get('origin') == self.service.settings.service_url + and hmac.compare_digest(args.get('csrf', ''), csrf(value)), + 'invalid_request', + ) + return value + + async def __call__(self, scope, receive, send): + if scope['type'] != 'http': + await self.app(scope, receive, send) + return + request = Request(scope, receive) + path = request.url.path + oauth_path = path.startswith('/oauth/') or path in { + '/.well-known/oauth-authorization-server', + '/.well-known/openid-configuration', + } + bearer = request.headers.get('authorization') + if not oauth_path and not (bearer and path.startswith('/-/p/')): + await self.app(scope, receive, send) + return + try: + raw = scope.get('raw_path') or path.encode() + require_safe_request_target( + raw, + scope.get('query_string', b''), + maximum=self.service.settings.server.limits.max_path_bytes, + ) + require_matching_host( + request.headers.getlist('host'), urlsplit(self.service.settings.service_url) + ) + require( + 'x-http-method-override' not in request.headers + and 'x-method-override' not in request.headers, + 'method_not_allowed', + ) + origin = request.headers.get('origin') + require( + origin is None or origin == self.service.settings.service_url, 'forbidden_origin' + ) + if not oauth_path: + require(request.method == 'POST', 'method_not_allowed') + packet = decode_packet( + await body_bytes( + request, self.service.settings.server.limits.max_request_bytes + ), + self.service.settings.server.limits.max_request_bytes, + ) + require(packet.proof is None, 'ambiguous_credentials') + async with self.service.metadata.transaction(write=False) as tx: + self.service.runtime_generation.require_current(tx) + credential = await self.oauth.bearer(tx, self.bearer_value(request)) + require(packet.subject == credential.subject_id, 'subject_mismatch') + from msg.core.codec import unb64 + + packet = replace( + packet, + proof=TokenProof( + credential_id=credential.id, + token=unb64(self.bearer_value(request).partition('.')[2], limit=32), + ), + ) + data = canonical(packet) + sent = False + + async def bound_receive(): + nonlocal sent + if sent: + return await receive() + sent = True + return {'type': 'http.request', 'body': data, 'more_body': False} + + await self.app(scope, bound_receive, send) + return + require(self.oauth.config.enabled, 'oauth_disabled') + response = await self.dispatch(request) + except Failure as exc: + status = ( + 503 + if exc.code in {'recovery_quarantined', 'recovery_runtime_stale'} + else (405 if exc.code == 'method_not_allowed' else 400) + ) + response = json({'error': exc.code}, status) + if exc.code == 'invalid_token': + response.status_code = 401 + response.headers['WWW-Authenticate'] = 'Bearer error="invalid_token"' + except Exception: + response = json({'error': 'server_error'}, 500) + await response(scope, receive, send) + + @staticmethod + def bearer_value(request): + value = request.headers.get('authorization', '') + require(value.startswith('Bearer ') and len(value) <= 300, 'invalid_token') + return value[7:] + + async def dispatch(self, request): + path, now = request.url.path, self.service.clock() + if path.startswith('/.well-known/') or path == '/oauth/jwks': + require(request.method in {'GET', 'HEAD'}, 'method_not_allowed') + base = self.service.settings.service_url + if path == '/oauth/jwks': + return json({'keys': [self.oauth.jwk()]}) + return json({ + 'issuer': base, + 'authorization_endpoint': base + '/oauth/authorize', + 'token_endpoint': base + '/oauth/token', + 'userinfo_endpoint': base + '/oauth/userinfo', + 'jwks_uri': base + '/oauth/jwks', + 'revocation_endpoint': base + '/oauth/revoke', + 'device_authorization_endpoint': base + '/oauth/device_authorization', + 'response_types_supported': ['code'], + 'subject_types_supported': ['public'], + 'id_token_signing_alg_values_supported': ['EdDSA'], + 'grant_types_supported': ['authorization_code', 'refresh_token', DEVICE_GRANT], + 'token_endpoint_auth_methods_supported': ['none'], + 'code_challenge_methods_supported': ['S256'], + 'scopes_supported': [ + 'openid', + 'profile', + 'offline_access', + 'msg.read', + 'msg.write', + ], + }) + # Rate limits commit independently, including rejected code guesses. + async with self.service.metadata.transaction(write=True) as tx: + self.oauth.rate(tx, request.client.host if request.client else '') + if path == '/oauth/login': + require(request.method == 'GET', 'method_not_allowed') + async with self.service.metadata.transaction(write=True) as tx: + self.oauth.fence(tx) + value, code, _ = self.oauth.pending(tx, kind='login') + response = page( + '登录 MSG', + '

在已登录的 CLI 中确认这次浏览器登录:

msg auth approve ' + + escape(code) + + '

等待确认…

' + '没有身份?申请托管 key

', + ) + self.cookie(response, self.login_cookie, value, 600) + return response + if path == '/oauth/signup': + if request.method == 'GET': + binder = secret() + response = page( + '申请托管 key', + '

服务器会保管签名和加密密钥。之后可升级为自行保管。

' + '
' + + fields({'csrf': csrf(binder)}) + + '' + '
', + ) + self.cookie(response, self.login_cookie, binder, 600) + return response + require(request.method == 'POST', 'method_not_allowed') + args = await form(request) + self.require_csrf(request, args, self.login_cookie) + packet = request_for( + 'identity.custodial_create', + { + 'handle': args.get('handle', ''), + 'nonce': b64(os.urandom(32)), + 'recovery_secret': b64(os.urandom(32)), + }, + self.service.settings.service_url, + contract_version=2, + expires_at=now + timedelta(seconds=180), + ) + result = await self.service.executor.execute(packet, entry='network') + require(result.status == 'ok', result.error.code if result.error else 'server_error') + from msg.core.codec import unb64 + from msg.core.models import Principal + + token = unb64(result.data['token']) + async with self.service.metadata.transaction(write=True) as tx: + self.oauth.fence(tx) + value, code, _ = self.oauth.pending(tx, kind='login') + credential = await tx.credential(result.data['credential_id']) + require( + hmac.compare_digest(credential.verifier, hashlib.sha256(token).digest()), + 'invalid_token', + ) + principal = Principal( + actor=result.subject, + subject=result.subject, + credential_id=credential.id, + method='token', + certificates=(), + ceiling=credential.ceiling, + ) + await self.oauth.approve(tx, principal, code, 'approve') + logged, error = await self.oauth.pending_result(tx, value, 'msg-cli', 'login') + require(error is None, 'server_error') + response = page( + '登录完成', + '

托管身份已创建。返回原授权页面继续。请保存登录态,退出后需用已授权 CLI 登录。

', + ) + self.cookie( + response, + self.session_cookie, + logged['cookie'], + int((logged['expires'] - now).total_seconds()), + ) + return response + if path == '/oauth/login/poll': + require(request.method == 'POST', 'method_not_allowed') + value = self.require_csrf(request, await form(request), self.login_cookie) + async with self.service.metadata.transaction(write=True) as tx: + self.oauth.fence(tx) + logged, error = await self.oauth.pending_result(tx, value, 'msg-cli', 'login') + response = json({'error': error}, 400) if error else json({'logged_in': True}) + if not error: + self.cookie( + response, + self.session_cookie, + logged['cookie'], + int((logged['expires'] - now).total_seconds()), + ) + response.delete_cookie( + self.login_cookie, path='/', secure=self.secure, httponly=True + ) + return response + if path in {'/oauth/authorize', '/oauth/device'}: + return await self.consent(request) + require( + request.method == 'POST' or (path == '/oauth/userinfo' and request.method == 'GET'), + 'method_not_allowed', + ) + args = await form(request) if request.method == 'POST' else {} + async with self.service.metadata.transaction(write=True) as tx: + self.oauth.fence(tx) + if path == '/oauth/device_authorization': + require(set(args) <= {'client_id', 'scope'}, 'invalid_request') + value, code, _ = self.oauth.pending( + tx, + kind='device', + client_id=args.get('client_id'), + scope=args.get('scope', 'openid profile msg.read offline_access'), + ) + uri = self.service.settings.service_url + '/oauth/device' + return json({ + 'device_code': value, + 'user_code': code, + 'verification_uri': uri, + 'verification_uri_complete': uri + '?' + urlencode({'user_code': code}), + 'expires_in': 600, + 'interval': 5, + }) + if path == '/oauth/token': + require( + set(args) + <= { + 'client_id', + 'grant_type', + 'code', + 'redirect_uri', + 'code_verifier', + 'device_code', + 'refresh_token', + }, + 'invalid_request', + ) + result, error = await self.oauth.exchange(tx, args) + return json({'error': error}, 400) if error else json(result) + if path == '/oauth/userinfo': + credential = await self.oauth.bearer(tx, self.bearer_value(request)) + _, binding = get(tx, 'access:' + credential.id, now) + _, family = get(tx, binding['family'], now) + require('openid' in family['scopes'], 'invalid_scope') + user = await tx.subject(credential.subject_id) + resource = await tx.resource(user.resource_id) + return json({ + 'sub': user.resource_id, + **( + {'preferred_username': resource.name.removeprefix('@')} + if 'profile' in family['scopes'] + else {} + ), + }) + if path == '/oauth/revoke': + from msg.security.oauth import client_for + + client_for(self.oauth.config, args.get('client_id')) + require(set(args) <= {'client_id', 'token', 'token_type_hint'}, 'invalid_request') + id = state_id('refresh', args.get('token', '')) + if args.get('token', '').startswith('t_oauth_'): + credential = await self.oauth.bearer(tx, args['token']) + id = 'access:' + credential.id + row = tx.one('SELECT body FROM oauth_states WHERE id=?', (id,)) + if row: + binding = loads(row[0]) + _, family = get(tx, binding['family'], now) + if family['client_id'] == args['client_id']: + family['revoked'] = True + save(tx, binding['family'], family) + return json({}) + if path == '/oauth/logout': + cookie = self.require_csrf(request, args, self.session_cookie) + _, body = get(tx, state_id('session', cookie), now) + body['revoked'] = True + save(tx, state_id('session', cookie), body) + response = json({'logged_out': True}) + response.delete_cookie( + self.session_cookie, path='/', secure=self.secure, httponly=True + ) + return response + raise Failure('invalid_request') + + async def consent(self, request): + require(request.method in {'GET', 'POST'}, 'method_not_allowed') + args = ( + unique(list(request.query_params.multi_items())) + if request.method == 'GET' + else await form(request) + ) + device = request.url.path == '/oauth/device' + auth_args = {k: v for k, v in args.items() if k not in {'csrf', 'decision'}} + async with self.service.metadata.transaction(write=True) as tx: + self.oauth.fence(tx) + if device: + require(set(auth_args) <= {'user_code'}, 'invalid_request') + code = auth_args.get('user_code', '').upper().replace('-', '') + if not code: + return page( + '设备登录', + '
' + '
', + ) + _, pending = get(tx, state_id('user', code), self.service.clock()) + require( + pending['kind'] == 'device' and pending['status'] == 'pending', 'invalid_grant' + ) + from msg.security.oauth import client_for + + client = client_for(self.oauth.config, pending['client_id']) + scopes = pending['scopes'] + else: + client = self.oauth.authorization(auth_args) + scopes = auth_args.get('scope', 'openid profile').split() + cookie = request.cookies.get(self.session_cookie, '') + try: + _, source = await self.oauth.session(tx, cookie) + except Failure as exc: + if exc.code != 'invalid_grant': + raise + require(request.method == 'GET', 'invalid_grant') + return page( + '登录后继续', + '

' + escape(client.name) + ' 请求使用你的 MSG 身份。

' + '

打开登录页面

' + '

登录完成后,刷新继续。

', + ) + if request.method == 'GET': + return page( + '授权给 ' + client.name, + '

身份:' + + escape(source['subject']) + + '

权限:' + + escape(' '.join(scopes)) + + '

' + + fields(dict(auth_args, csrf=csrf(cookie))) + + ' ' + '
', + ) + self.require_csrf(request, args, self.session_cookie) + require(args.get('decision') in {'approve', 'deny'}, 'invalid_request') + if device: + id = state_id('user', code) + if args['decision'] == 'approve': + pending.update(source) + pending['status'] = 'approved' if args['decision'] == 'approve' else 'denied' + save(tx, id, pending) + return page('已确认', '

可以返回 CLI 继续。

') + redirect = {'state': auth_args['state']} + if args['decision'] == 'approve': + auth_args.setdefault('scope', 'openid profile') + redirect['code'] = await self.oauth.authorize(tx, cookie, auth_args) + else: + redirect['error'] = 'access_denied' + return RedirectResponse( + auth_args['redirect_uri'] + '?' + urlencode(redirect), + status_code=303, + headers=HEADERS, + ) diff --git a/tests/test_complete_recovery_proof.py b/tests/test_complete_recovery_proof.py index f7106cce..6e597936 100644 --- a/tests/test_complete_recovery_proof.py +++ b/tests/test_complete_recovery_proof.py @@ -50,18 +50,30 @@ async def test_complete_proof_accepts_exact_disposable_state(complete_state): @pytest.mark.parametrize( - 'change', ['extra_setting', 'extra_table', 'extra_column', 'wrong_pin', 'partial'] + 'change', + ['extra_setting', 'extra_table', 'extra_column', 'oauth_state', 'wrong_pin', 'partial'], ) async def test_complete_proof_rejects_unaccounted_state(complete_state, change): app, root, packet, pin = complete_state - if change in {'extra_setting', 'extra_table', 'extra_column'}: + if change in {'extra_setting', 'extra_table', 'extra_column', 'oauth_state'}: async with app.metadata.transaction(write=True) as tx: if change == 'extra_setting': tx.set_setting('recovery_promotion_attacker', {'authority': True}) elif change == 'extra_table': tx.execute('CREATE TABLE hidden_authority (id TEXT)', write=True) - else: + elif change == 'extra_column': tx.execute('ALTER TABLE resources ADD COLUMN hidden_authority TEXT', write=True) + else: + tx.execute( + 'INSERT INTO oauth_states VALUES (?,?,?,?)', + ( + 'unaccounted-oauth-session', + 'session', + '2999-01-01T00:00:00Z', + canonical({'subject': 's_unaccounted'}).decode(), + ), + write=True, + ) elif change == 'wrong_pin': from dataclasses import replace diff --git a/tests/test_hosting_runtime.py b/tests/test_hosting_runtime.py index 0c78816b..be864304 100644 --- a/tests/test_hosting_runtime.py +++ b/tests/test_hosting_runtime.py @@ -25,6 +25,7 @@ 'identities', 'memberships', 'credentials', + 'oauth_states', 'certificates', 'settings', 'share_grants', diff --git a/tests/test_ledger_migration_json.py b/tests/test_ledger_migration_json.py index 6c325bc4..32e14ad2 100644 --- a/tests/test_ledger_migration_json.py +++ b/tests/test_ledger_migration_json.py @@ -54,6 +54,37 @@ async def test_json_reference_contract_covers_the_current_schema(installed): assert actual == set(_JSON_IDENTITY_REFS) +@pytest.mark.parametrize('reference', ['subject', 'ceiling']) +async def test_migration_refuses_oauth_identity_references(installed, reference): + app, root = installed + _, _, _, _, bounty, order = await _market_accounts(app, root) + escrows = ( + bounty.data['funding']['body']['to_subject'], + order.data['payment']['body']['to_subject'], + ) + await _restore_legacy_escrow_identities(app, escrows) + body = ( + {'subject': escrows[0]} + if reference == 'subject' + else {'ceiling': [{'scope': {'resource_id': escrows[0]}}]} + ) + encoded = json.dumps(body) + async with app.metadata.transaction(write=True) as tx: + tx.execute( + 'INSERT INTO oauth_states VALUES (?,?,?,?)', + ('migration-oauth', 'session', '2999-01-01T00:00:00Z', encoded), + write=True, + ) + with pytest.raises(RuntimeError, match='active reference'): + PostgresMetadataStore(app.settings.server.postgres_dsn) + async with app.metadata.transaction(write=False) as tx: + assert ( + tx.one('SELECT body FROM oauth_states WHERE id=?', ('migration-oauth',))[0] == encoded + ) + assert tx.one('SELECT COUNT(*) FROM identities WHERE id IN (?,?)', escrows)[0] == 2 + assert tx.one('SELECT COUNT(*) FROM ledger_accounts WHERE id IN (?,?)', escrows)[0] == 0 + + def test_reference_paths_ignore_untyped_dictionary_keys(): from msg.storage.ledger_migration import _values_at diff --git a/tests/test_oauth.py b/tests/test_oauth.py new file mode 100644 index 00000000..ecf441f8 --- /dev/null +++ b/tests/test_oauth.py @@ -0,0 +1,544 @@ +"""OAuth acceptance through real PostgreSQL, identity verification and HTTP.""" + +import hashlib +import os +from dataclasses import replace +from datetime import timedelta +from urllib.parse import parse_qs, urlencode, urlsplit + +import httpx +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from test_service import NOW, call, register + +from msg.client import ClientState, MsgClient +from msg.client_api_keys import create as create_api_key +from msg.client_oauth import read_session, refresh, save_session +from msg.core.codec import b64, canonical, loads, unb64, wire +from msg.core.errors import Failure +from msg.core.requests import request_for +from msg.oauth_config import OAuthClient, OAuthConfig, load_oauth +from msg.security.oauth import DEVICE_GRANT +from msg.transports.client import HTTPTransport +from msg.transports.http import create_app +from msg.transports.oauth_http import csrf + + +@pytest.fixture +async def oauth(installed): + app, _ = installed + app.settings = replace( + app.settings, + oauth=OAuthConfig( + enabled=True, + clients=( + OAuthClient('msg-cli', 'MSG CLI'), + OAuthClient('example', 'Example', ('https://example.com/callback',)), + ), + ), + ) + app.authenticator.oauth_config = app.settings.oauth + app._oauth_clock = [NOW] + app.clock = lambda: app._oauth_clock[0] + app.authenticator.clock = app.clock + app.executor.clock = app.clock + app.certificates.clock = app.clock + key, subject, _ = await register(app, 'oauth-owner') + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + yield app, key, subject, http + + +async def device_tokens(oauth, scope='openid profile msg.read offline_access'): + app, key, subject, http = oauth + response = await http.post( + '/oauth/device_authorization', json={'client_id': 'msg-cli', 'scope': scope} + ) + assert response.status_code == 200, response.text + pending = response.json() + approved = await call( + app, + 'identity.oauth_approve', + {'user_code': pending['user_code'], 'decision': 'approve'}, + key=key, + subject=subject, + ) + assert approved.status == 'ok', wire(approved) + result = await http.post( + '/oauth/token', + json={ + 'grant_type': DEVICE_GRANT, + 'client_id': 'msg-cli', + 'device_code': pending['device_code'], + }, + ) + assert result.status_code == 200, result.text + return result.json() + + +async def browser_login(oauth): + app, key, subject, http = oauth + page = await http.get('/oauth/login') + assert page.status_code == 200 + # Observe the actual displayed approval code, not private database state. + code = page.text.split('msg auth approve ')[1].split('')[0] + approved = await call( + app, + 'identity.oauth_approve', + {'user_code': code, 'decision': 'approve'}, + key=key, + subject=subject, + ) + assert approved.status == 'ok', wire(approved) + cookie = http.cookies.get('msg_login') + result = await http.post( + '/oauth/login/poll', + json={'csrf': csrf(cookie)}, + headers={'Origin': app.settings.service_url}, + ) + assert result.status_code == 200 and result.json() == {'logged_in': True}, result.text + header = result.headers['set-cookie'] + assert 'HttpOnly' in header and 'SameSite=lax' in header + return http.cookies.get('msg_session') + + +@pytest.mark.asyncio +async def test_device_flow_pending_denial_replay_and_poll_backoff(oauth): + app, key, subject, http = oauth + start = (await http.post('/oauth/device_authorization', data={'client_id': 'msg-cli'})).json() + params = { + 'grant_type': DEVICE_GRANT, + 'client_id': 'msg-cli', + 'device_code': start['device_code'], + } + assert (await http.post('/oauth/token', data=params)).json()['error'] == 'authorization_pending' + assert (await http.post('/oauth/token', data=params)).json()['error'] == 'slow_down' + denied = await call( + app, + 'identity.oauth_approve', + {'user_code': start['user_code'], 'decision': 'deny'}, + key=key, + subject=subject, + ) + assert denied.status == 'ok' + app._oauth_clock[0] = NOW + timedelta(seconds=15) + assert (await http.post('/oauth/token', data=params)).json()['error'] == 'access_denied' + again = await call( + app, + 'identity.oauth_approve', + {'user_code': start['user_code'], 'decision': 'approve'}, + key=key, + subject=subject, + ) + assert again.status == 'error' and again.error.code == 'invalid_grant' + + +@pytest.mark.asyncio +async def test_device_tokens_userinfo_scopes_and_one_time_consumption(oauth): + app, _, subject, http = oauth + tokens = await device_tokens(oauth) + info = await http.get( + '/oauth/userinfo', headers={'Authorization': 'Bearer ' + tokens['access_token']} + ) + assert info.status_code == 200 and info.json() == { + 'sub': subject, + 'preferred_username': 'oauth-owner', + }, info.text + credential, _, encoded = tokens['access_token'].partition('.') + result = await call( + app, 'discovery.get', {'id': '/main'}, subject=subject, token=(credential, unb64(encoded)) + ) + assert result.status == 'ok' and result.actor == subject, wire(result) + denied = await call( + app, + 'content.post_create', + {'parent': '/main', 'body': 'no write scope'}, + subject=subject, + token=(credential, unb64(encoded)), + ) + assert denied.status == 'error' and denied.error.code == 'credential_ceiling' + async with app.metadata.transaction(write=False) as tx: + persisted = canonical(tx.rows('SELECT body FROM oauth_states')).decode() + persisted += canonical(tx.rows('SELECT body FROM results')).decode() + for value in (tokens['access_token'], encoded, tokens['refresh_token']): + assert value not in persisted + + +@pytest.mark.asyncio +async def test_refresh_rotation_reuse_revokes_family_and_absolute_expiry(oauth): + app, _, _, http = oauth + initial = await device_tokens(oauth) + data = { + 'client_id': 'msg-cli', + 'grant_type': 'refresh_token', + 'refresh_token': initial['refresh_token'], + } + rotated = await http.post('/oauth/token', json=data) + assert rotated.status_code == 200, rotated.text + new = rotated.json() + assert new['refresh_token'] != initial['refresh_token'] + assert new['refresh_expires_at'] == initial['refresh_expires_at'] + assert (await http.post('/oauth/token', json=data)).json()['error'] == 'invalid_grant' + assert ( + await http.get( + '/oauth/userinfo', headers={'Authorization': 'Bearer ' + new['access_token']} + ) + ).status_code != 200 + assert ( + await http.post('/oauth/token', json=dict(data, refresh_token=new['refresh_token'])) + ).json()['error'] == 'invalid_grant' + + +@pytest.mark.asyncio +async def test_pkce_cookie_csrf_oidc_and_code_binding(oauth): + app, _, subject, http = oauth + cookie = await browser_login(oauth) + verifier = b64(os.urandom(48)) + args = { + 'client_id': 'example', + 'response_type': 'code', + 'redirect_uri': 'https://example.com/callback', + 'scope': 'openid profile msg.read offline_access', + 'state': 'state-with-enough-entropy', + 'nonce': 'nonce-with-enough-entropy', + 'code_challenge_method': 'S256', + 'code_challenge': b64(hashlib.sha256(verifier.encode()).digest()), + } + page = await http.get('/oauth/authorize?' + urlencode(args)) + assert page.status_code == 200 and '同意' in page.text + bad = await http.post( + '/oauth/authorize', + data=dict(args, csrf='wrong', decision='approve'), + headers={'Origin': app.settings.service_url}, + ) + assert bad.status_code == 400 + authorized = await http.post( + '/oauth/authorize', + data=dict(args, csrf=csrf(cookie), decision='approve'), + headers={'Origin': app.settings.service_url}, + ) + assert authorized.status_code == 303, authorized.text + query = parse_qs(urlsplit(authorized.headers['location']).query) + assert query['state'] == [args['state']] + data = { + 'client_id': 'example', + 'grant_type': 'authorization_code', + 'code': query['code'][0], + 'redirect_uri': args['redirect_uri'], + 'code_verifier': verifier, + } + wrong = await http.post('/oauth/token', json=dict(data, code_verifier='x' * 43)) + assert wrong.status_code == 400 and wrong.json()['error'] == 'invalid_grant' + response = await http.post('/oauth/token', data=data) + assert response.status_code == 200, response.text + tokens = response.json() + header, claims, signature = tokens['id_token'].split('.') + jwk = (await http.get('/oauth/jwks')).json()['keys'][0] + Ed25519PublicKey.from_public_bytes(unb64(jwk['x'])).verify( + unb64(signature), (header + '.' + claims).encode() + ) + body = loads(unb64(claims)) + assert body['iss'] == app.settings.service_url and body['aud'] == 'example' + assert body['sub'] == subject and body['nonce'] == args['nonce'] + assert (await http.post('/oauth/token', data=data)).json()['error'] == 'invalid_grant' + # Logout invalidates all grants made using this browser session. + logout = await http.post( + '/oauth/logout', json={'csrf': csrf(cookie)}, headers={'Origin': app.settings.service_url} + ) + assert logout.status_code == 200 + assert ( + await http.get( + '/oauth/userinfo', headers={'Authorization': 'Bearer ' + tokens['access_token']} + ) + ).status_code != 200 + + +@pytest.mark.asyncio +@pytest.mark.parametrize('mutation', ['parent', 'authority', 'quarantine', 'runtime']) +async def test_oauth_never_revives_revoked_or_restored_authority(oauth, mutation): + app, _, _, http = oauth + tokens = await device_tokens(oauth) + async with app.metadata.transaction(write=True) as tx: + if mutation == 'parent': + key = oauth[1] + old = await tx.credential(key.key_id) + subject = await tx.subject(old.subject_id) + await tx.save_credential(replace(old, revoked_at=NOW), subject.auth_version) + elif mutation == 'authority': + subject = await tx.subject(oauth[2]) + await tx.update_identity( + replace(subject, auth_version=subject.auth_version + 1), subject.auth_version + ) + else: + tx.set_setting( + 'recovery_quarantine' + if mutation == 'quarantine' + else 'recovery_runtime_generation', + 'changed', + ) + response = await http.post( + '/oauth/token', + json={ + 'client_id': 'msg-cli', + 'grant_type': 'refresh_token', + 'refresh_token': tokens['refresh_token'], + }, + ) + assert response.status_code >= 400 + assert ( + await http.get( + '/oauth/userinfo', headers={'Authorization': 'Bearer ' + tokens['access_token']} + ) + ).status_code >= 400 + + +@pytest.mark.asyncio +async def test_persistent_client_refresh_and_private_file_permissions(oauth, tmp_path): + app, _, subject, http = oauth + tokens = await device_tokens(oauth) + state = ClientState(tmp_path / 'cli', server=app.settings.service_url) + client = MsgClient(state, HTTPTransport(state.server, http=http), clock=lambda: app.clock()) + save_session(client, tokens) + assert state.subject == subject + assert (state.directory / 'oauth-session.json').stat().st_mode & 0o777 == 0o600 + reloaded = MsgClient(ClientState(state.directory), client.transport, clock=lambda: app.clock()) + app._oauth_clock[0] = NOW + timedelta(seconds=910) + result = await reloaded.call('discovery.get', {'id': '/main'}) + assert result.status == 'ok' and result.actor == subject, wire(result) + saved = read_session(reloaded.state) + assert saved['refresh_token'] != tokens['refresh_token'] + assert not saved.get('refresh_pending') + (state.directory / 'oauth-session.json').chmod(0o644) + with pytest.raises(Failure, match='unsafe_oauth_session'): + await refresh(reloaded) + + +@pytest.mark.asyncio +async def test_api_key_private_signature_rotation_delivery_and_header(oauth, tmp_path): + app, _, _, http = oauth + state = ClientState(tmp_path / 'api', server=app.settings.service_url) + client = MsgClient(state, HTTPTransport(state.server, http=http), clock=lambda: app.clock()) + registered = await client.register('api-owner') + assert registered.status == 'ok' + initial = await create_api_key(client, ttl=60) + assert initial.status == 'ok', wire(initial) + first = dict(state.data['api_key']) + result = await client.call('discovery.get', {'id': '/main'}) + assert result.status == 'ok' and result.actor == state.subject, wire(result) + rotated = await create_api_key(client, ttl=120, rotate=True) + assert rotated.status == 'ok', wire(rotated) + denied = await call( + app, + 'discovery.get', + {'id': '/main'}, + subject=state.subject, + token=(first['credential_id'], unb64(first['value'])), + ) + assert denied.status == 'error' and denied.error.code == 'credential_revoked' + current = state.data['api_key'] + packet = request_for( + 'discovery.get', + {'id': '/main'}, + state.server, + subject=state.subject, + expires_at=NOW + timedelta(seconds=120), + ) + response = await http.post( + '/-/p/discovery.get', + json=wire(packet), + headers={'Authorization': 'Bearer ' + current['credential_id'] + '.' + current['value']}, + ) + assert response.status_code == 200 and response.json()['actor'] == state.subject, response.text + app._oauth_clock[0] = NOW + timedelta(seconds=121) + packet = request_for( + 'discovery.get', + {'id': '/main'}, + state.server, + subject=state.subject, + token=(current['credential_id'], unb64(current['value'])), + expires_at=app.clock() + timedelta(seconds=120), + ) + result = await app.executor.execute(packet) + assert result.status == 'error' and result.error.code == 'credential_expired' + + +@pytest.mark.asyncio +async def test_custodial_signup_login_and_device_consent_reuses_vault(oauth): + app, _, _, http = oauth + await http.get('/oauth/signup') + binder = http.cookies.get('msg_login') + response = await http.post( + '/oauth/signup', + data={'handle': 'hosted-oauth', 'csrf': csrf(binder)}, + headers={'Origin': app.settings.service_url}, + ) + assert response.status_code == 200, response.text + cookie = http.cookies.get('msg_session') + pending = ( + await http.post( + '/oauth/device_authorization', + data={'client_id': 'msg-cli', 'scope': 'openid profile msg.read offline_access'}, + ) + ).json() + form_page = await http.get('/oauth/device?' + urlencode({'user_code': pending['user_code']})) + assert form_page.status_code == 200 and '同意' in form_page.text + response = await http.post( + '/oauth/device', + data={'user_code': pending['user_code'], 'decision': 'approve', 'csrf': csrf(cookie)}, + headers={'Origin': app.settings.service_url}, + ) + assert response.status_code == 200, response.text + tokens = ( + await http.post( + '/oauth/token', + data={ + 'client_id': 'msg-cli', + 'grant_type': DEVICE_GRANT, + 'device_code': pending['device_code'], + }, + ) + ).json() + assert 'access_token' in tokens, tokens + # The original one-hour bootstrap token is no longer needed for the login. + app._oauth_clock[0] = NOW + timedelta(seconds=3601) + response = await http.post( + '/oauth/token', + data={ + 'client_id': 'msg-cli', + 'grant_type': 'refresh_token', + 'refresh_token': tokens['refresh_token'], + }, + ) + assert response.status_code == 200, response.text + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT COUNT(*) FROM custodial_vault')[0] == 1 + + +@pytest.mark.parametrize( + 'uri', + [ + 'http://example.com/cb', + 'https://example.com/cb#x', + 'https://user@example.com/cb', + 'https://example.com/cb?other=1', + ], +) +def test_redirect_registration_rejects_unsafe_uris(uri): + with pytest.raises(Failure, match='invalid_redirect_uri'): + load_oauth( + { + 'enabled': True, + 'clients': [{'client_id': 'example', 'name': 'Example', 'redirect_uris': [uri]}], + }, + 'https://msg.lmm.best', + ) + + +@pytest.mark.asyncio +async def test_api_key_lost_response_recovers_with_source_binding(oauth, tmp_path): + app, _, _, http = oauth + state = ClientState(tmp_path / 'lost-api', server=app.settings.service_url) + transport = HTTPTransport(state.server, http=http) + client = MsgClient(state, transport, clock=lambda: app.clock(), retries=0) + await client.register('lost-api') + private = state.signer.private_bytes() + original = transport.call + + async def lose(packet): + result = await original(packet) + if packet.operation == 'identity.token_create': + raise httpx.ReadTimeout('lost response') + return result + + transport.call = lose + with pytest.raises(Failure, match='transport_uncertain'): + await create_api_key(client) + journal = state.directory / 'api-key-create.json' + assert journal.exists() and state.data.get('api_key') is None + restarted = MsgClient( + ClientState(state.directory), + HTTPTransport(state.server, http=http), + clock=lambda: app.clock(), + ) + recovered = await restarted.recover_token() + assert recovered.status == 'ok', wire(recovered) + assert not journal.exists() and restarted.state.token is None + assert restarted.state.signer.private_bytes() == private + assert restarted.state.data['api_key']['credential_id'] == recovered.data['credential_id'] + result = await restarted.call('discovery.get', {'id': '/main'}) + assert result.status == 'ok', wire(result) + saved = restarted.state.data['api_key'] + async with app.metadata.transaction(write=True) as tx: + credential = await tx.credential(saved['credential_id']) + assert credential.source_credential_id == state.signer.key_id + tx.execute('DELETE FROM oauth_states WHERE id=?', ('api:' + credential.id,), write=True) + denied = await restarted.call('discovery.get', {'id': '/main'}) + assert denied.status == 'error' and denied.error.code == 'invalid_grant' + + +@pytest.mark.asyncio +async def test_concurrent_refresh_consumes_once_and_revokes_reused_family(oauth): + import asyncio + + _, _, _, http = oauth + tokens = await device_tokens(oauth) + body = { + 'client_id': 'msg-cli', + 'grant_type': 'refresh_token', + 'refresh_token': tokens['refresh_token'], + } + replies = await asyncio.gather( + http.post('/oauth/token', json=body), http.post('/oauth/token', json=body) + ) + assert sorted(r.status_code for r in replies) == [200, 400] + issued = next(r.json() for r in replies if r.status_code == 200) + assert ( + await http.get( + '/oauth/userinfo', headers={'Authorization': 'Bearer ' + issued['access_token']} + ) + ).status_code == 401 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'patch', + [ + {'client_id': 'unknown'}, + {'redirect_uri': 'https://example.com/callback/extra'}, + {'redirect_uri': 'https://attacker.example/callback'}, + {'code_challenge_method': 'plain'}, + {'scope': 'openid admin'}, + {'state': 'tiny'}, + {'response_type': 'token'}, + ], +) +async def test_browser_rejects_unregistered_or_downgraded_authorization(oauth, patch): + _, _, _, http = oauth + args = { + 'client_id': 'example', + 'response_type': 'code', + 'redirect_uri': 'https://example.com/callback', + 'scope': 'openid profile', + 'state': 'state-with-enough-entropy', + 'code_challenge_method': 'S256', + 'code_challenge': b64(os.urandom(32)), + } + response = await http.get('/oauth/authorize?' + urlencode(dict(args, **patch))) + assert response.status_code == 400 and 'location' not in response.headers + + +@pytest.mark.asyncio +async def test_device_grant_cannot_be_redeemed_twice(oauth): + app, key, subject, http = oauth + start = (await http.post('/oauth/device_authorization', data={'client_id': 'msg-cli'})).json() + await call( + app, + 'identity.oauth_approve', + {'user_code': start['user_code'], 'decision': 'approve'}, + key=key, + subject=subject, + ) + body = {'client_id': 'msg-cli', 'grant_type': DEVICE_GRANT, 'device_code': start['device_code']} + assert (await http.post('/oauth/token', json=body)).status_code == 200 + assert (await http.post('/oauth/token', json=body)).json()['error'] == 'invalid_grant'