diff --git a/README.md b/README.md index 686693c..952f0ee 100644 --- a/README.md +++ b/README.md @@ -32,15 +32,31 @@ All requests carry `Authorization: Bearer `. `tinyui publish` / `tinyui releases …` in `tinyui-cli` are thin clients of these endpoints. -## Management (`ADMIN_TOKEN`) +## Management -| Request | Meaning | -|---|---| -| `POST /apps` `{ id, name, org? }` | create an app (`id` is a path segment; `org` is for billing only) | -| `POST /apps//packages` `{ name, publicKey }` | register a package and its verification key | -| `PUT /apps//packages//publicKey` | rotate the key | -| `POST /apps//packages//tokens` `{ channels }` | issue a publish token limited to channels; returned once, stored as a hash | -| `DELETE /apps//packages//tokens/` | revoke | +An **app** is one host App: paths, host versions, snapshots and permissions hang off it. Its owner for billing (`org`, the tenant) can be renamed or transferred, so it appears in no path and grants nothing; one tenant may own several apps. + +Three layers of credentials, each issued by the one above: + +| Credential | Reaches | Issued by | +|---|---|---| +| the instance's `ADMIN_TOKEN` | every app; the only one that creates apps and issues app tokens | `wrangler secret put` | +| app token | everything under one app: packages, keys, publish tokens, releases, host snapshots | `ADMIN_TOKEN` | +| publish token | one package, limited to channels; can read its app's host snapshots | `ADMIN_TOKEN` or an app token of that app | + +| Request | Credential | Meaning | +|---|---|---| +| `POST /apps` `{ id, name, org? }` | admin | create an app (`id` is a path segment) | +| `POST /apps//tokens` | admin | issue an app token; returned once, stored as a hash | +| `DELETE /apps//tokens/` | admin | revoke it; an app token cannot mint or revoke app tokens | +| `POST /apps//packages` `{ name, publicKey }` | admin or app token | register a package and its verification key | +| `PUT /apps//packages//publicKey` | admin or app token | rotate the key | +| `POST /apps//packages//tokens` `{ channels }` | admin or app token | issue a publish token limited to channels; returned once, stored as a hash | +| `DELETE /apps//packages//tokens/` | admin or app token | revoke | +| `PUT /apps//hosts/` (snapshot bytes) | admin or app token | the host's CI uploads what that host version provides; written once, different bytes → 409 | +| `GET /apps//hosts/` | any token of the app | read it back; `tinyui bundle` checks a package against it before signing | + +None of these reach the clients' trust: devices verify with the key embedded in the App. ## Self-hosting diff --git a/README.zh-CN.md b/README.zh-CN.md index aac4cee..0dc9806 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -32,15 +32,31 @@ `tinyui-cli` 的 `tinyui publish` / `tinyui releases …` 是这些端点的薄封装。 -## 管理(`ADMIN_TOKEN`) +## 管理 -| 请求 | 语义 | -|---|---| -| `POST /apps` `{ id, name, org? }` | 建 app(`id` 是路径段;`org` 只用于计费归属) | -| `POST /apps//packages` `{ name, publicKey }` | 登记包与其验签公钥 | -| `PUT /apps//packages//publicKey` | 轮换公钥 | -| `POST /apps//packages//tokens` `{ channels }` | 签发限定 channel 的发布 token;只返回一次,服务端只存哈希 | -| `DELETE /apps//packages//tokens/` | 吊销 | +**app** 是一个宿主 App:路径、宿主版本、快照与权限都挂在它上面。它的计费归属(`org`,即租户)可以改名、转让,所以不进路径、不带权限;一个租户可以有多个 app。 + +凭据三层,下层由上层签发: + +| 凭据 | 范围 | 签发者 | +|---|---|---| +| 实例的 `ADMIN_TOKEN` | 所有 app;只有它能建 app、签发 app token | `wrangler secret put` | +| app token | 一个 app 下的一切:包、公钥、发布 token、release、宿主快照 | `ADMIN_TOKEN` | +| 发布 token | 一个包,限定 channel;可读本 app 的宿主快照 | `ADMIN_TOKEN` 或本 app 的 app token | + +| 请求 | 凭据 | 语义 | +|---|---|---| +| `POST /apps` `{ id, name, org? }` | admin | 建 app(`id` 是路径段) | +| `POST /apps//tokens` | admin | 签发 app token;只返回一次,服务端只存哈希 | +| `DELETE /apps//tokens/` | admin | 吊销;app token 不能签发或吊销 app token | +| `POST /apps//packages` `{ name, publicKey }` | admin 或 app token | 登记包与其验签公钥 | +| `PUT /apps//packages//publicKey` | admin 或 app token | 轮换公钥 | +| `POST /apps//packages//tokens` `{ channels }` | admin 或 app token | 签发限定 channel 的发布 token;只返回一次,服务端只存哈希 | +| `DELETE /apps//packages//tokens/` | admin 或 app token | 吊销 | +| `PUT /apps//hosts/`(快照字节) | admin 或 app token | 宿主 CI 上传该宿主版本提供了什么;只写一次,不同字节 → 409 | +| `GET /apps//hosts/` | 本 app 的任一 token | 读回;`tinyui bundle` 签名前据此核对包 | + +这些都碰不到客户端的信任链:设备只认 App 内置的公钥。 ## 私有化部署 diff --git a/src/app.ts b/src/app.ts index ad0642f..7ba9b9a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; import type { Context } from "hono"; -import { docKeys, isAdmin, issueToken, packageToken, revokeToken } from "./auth.ts"; +import { anyPackageToken, canManage, docKeys, isAdmin, issueAppToken, issueToken, packageToken, revokeAppToken, revokeToken } from "./auth.ts"; import { isHostVersion, isName, isObjectPath, isSegment } from "./ids.ts"; import { parseManifest } from "./manifest.ts"; import type { AppRecord, PackageRecord, PointerDoc, ReleaseRecord } from "./records.ts"; @@ -19,13 +19,23 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono { const app = new Hono(); const fail = (c: Context, status: 400 | 401 | 403 | 404 | 409 | 413, error: string) => c.json({ error }, status); const objectKey = (app: string, pkg: string, hostVersion: string, version: string, path: string) => `${app}/${pkg}/${hostVersion}/${version}/${path}`; + // `_` is not allowed in an app id, so these keys never meet a package's content + const hostKey = (app: string, hostVersion: string) => `_hosts/${app}/${hostVersion}`; app.get("/", (c) => c.text("tinyui-updates")); // ---- management (ADMIN_TOKEN), tinyui docs/updates.md §6.3 ---- - app.use("/apps/*", async (c, next) => (isAdmin(c, adminToken) ? next() : fail(c, 401, "admin token required"))); - app.post("/apps", async (c, next) => (isAdmin(c, adminToken) ? next() : fail(c, 401, "admin token required"))); + const admin = async (c: Context, next: () => Promise) => (isAdmin(c, adminToken) ? next() : fail(c, 401, "admin token required")); + // everything else under /apps/: the admin token or that app's app token; ids are checked by each route + const manager = async (c: Context, next: () => Promise) => + (await canManage(c, storage, adminToken, c.req.param("app") ?? "")) ? next() : fail(c, 401, "the admin token or an app token of this app is required"); + app.post("/apps", admin); + app.post("/apps/:app/tokens", admin); + app.delete("/apps/:app/tokens/:tokenId", admin); + app.post("/apps/:app/packages", manager); + app.on(["POST", "PUT", "DELETE"], "/apps/:app/packages/*", manager); + app.put("/apps/:app/hosts/:hostVersion", manager); app.post("/apps", async (c) => { const body = await json(c); @@ -41,6 +51,43 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono { return c.json(record, 201); }); + app.post("/apps/:app/tokens", async (c) => { + const appId = c.req.param("app"); + if (!isName(appId) || !(await storage.getDoc(docKeys.app(appId)))) return fail(c, 404, `no app ${appId}`); + const { token, record } = await issueAppToken(storage, appId); + return c.json({ id: record.id, token, createdAt: record.createdAt }, 201); + }); + + app.delete("/apps/:app/tokens/:tokenId", async (c) => { + const { app: appId, tokenId } = c.req.param(); + if (!isName(appId) || !(await revokeAppToken(storage, appId, tokenId))) return fail(c, 404, "no such token"); + return c.body(null, 204); + }); + + // host snapshots, §6.4: written once per (app, hostVersion) by the host's CI, read back by `tinyui bundle` + app.put("/apps/:app/hosts/:hostVersion", async (c) => { + const { app: appId, hostVersion } = c.req.param(); + if (!isName(appId) || !isHostVersion(hostVersion) || !(await storage.getDoc(docKeys.app(appId)))) return fail(c, 404, "not found"); + const body = await readBounded(c.req.raw.body, maxObjectBytes); + if (!body) return fail(c, 413, `snapshots are limited to ${maxObjectBytes} bytes`); + if (body.byteLength === 0) return fail(c, 400, "a snapshot cannot be empty"); + const sha256 = await sha256Hex(body); + const key = hostKey(appId, hostVersion); + if (await storage.putObjectIfAbsent(key, body, sha256)) return c.json({ hostVersion, sha256, existing: false }, 201); + const existing = await storage.headObject(key); + if (existing?.sha256 === sha256) return c.json({ hostVersion, sha256, existing: true }); + return fail(c, 409, `host version ${hostVersion} of ${appId} already has a different snapshot; a shipped host version is frozen`); + }); + + app.get("/apps/:app/hosts/:hostVersion", async (c) => { + const { app: appId, hostVersion } = c.req.param(); + if (!isName(appId) || !isHostVersion(hostVersion)) return fail(c, 404, "not found"); + if (!(await canManage(c, storage, adminToken, appId)) && !(await anyPackageToken(c, storage, appId))) return fail(c, 401, "a token of this app is required"); + const object = await storage.getObject(hostKey(appId, hostVersion)); + if (!object) return fail(c, 404, `host version ${hostVersion} of ${appId} has no snapshot; upload it from the host's CI`); + return c.body(object.body as unknown as ArrayBuffer, 200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }); + }); + app.post("/apps/:app/packages", async (c) => { const appId = c.req.param("app"); if (!isName(appId) || !(await storage.getDoc(docKeys.app(appId)))) return fail(c, 404, `no app ${appId}`); @@ -89,7 +136,7 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono { app.get("/:app/:pkg/:hostVersion/releases", async (c) => { const { app: appId, pkg, hostVersion } = c.req.param(); if (!isName(appId) || !isName(pkg) || !isHostVersion(hostVersion)) return fail(c, 404, "not found"); - if (!isAdmin(c, adminToken) && !(await packageToken(c, storage, appId, pkg))) return fail(c, 401, "a token for this package is required"); + if (!(await canManage(c, storage, adminToken, appId)) && !(await packageToken(c, storage, appId, pkg))) return fail(c, 401, "a token for this package is required"); const releasePrefix = docKeys.releasePrefix(appId, pkg, hostVersion); const versions: { version: string; createdAt: string; publishedAt: string }[] = []; for (const key of await storage.listDocs(releasePrefix)) { diff --git a/src/auth.ts b/src/auth.ts index 67791d3..9c24c45 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -10,11 +10,20 @@ export interface TokenRecord { createdAt: string; } +/** An app token: the whole management of one app, issued by the admin token only (tinyui docs/updates.md §6). */ +export interface AppTokenRecord { + kind: "app"; + id: string; + app: string; + createdAt: string; +} + export const docKeys = { app: (app: string) => `app:${app}`, pkg: (app: string, pkg: string) => `pkg:${app}:${pkg}`, tokenByHash: (hash: string) => `token:${hash}`, tokenById: (app: string, pkg: string, id: string) => `tokenid:${app}:${pkg}:${id}`, + appTokenById: (app: string, id: string) => `apptokenid:${app}:${id}`, // one document per version and per channel: nothing is ever read, modified and written back release: (app: string, pkg: string, hostVersion: string, version: string) => `${docKeys.releasePrefix(app, pkg, hostVersion)}${version}`, releasePrefix: (app: string, pkg: string, hostVersion: string) => `release:${app}:${pkg}:${hostVersion}:`, @@ -35,22 +44,55 @@ export function isAdmin(c: Context, adminToken: string): boolean { /** The token record the request carries when it is a live token of (app, pkg); null otherwise. */ export async function packageToken(c: Context, storage: Storage, app: string, pkg: string): Promise { + const record = await presented(c, storage); + return record && !isAppToken(record) && record.app === app && record.pkg === pkg ? record : null; +} + +/** A live publish token of any package of [app]: it may read that app's host snapshots. */ +export async function anyPackageToken(c: Context, storage: Storage, app: string): Promise { + const record = await presented(c, storage); + return !!record && !isAppToken(record) && record.app === app; +} + +/** The admin token, or a live app token of [app]: everything under /apps/ except minting app tokens. */ +export async function canManage(c: Context, storage: Storage, adminToken: string, app: string): Promise { + if (isAdmin(c, adminToken)) return true; + const record = await presented(c, storage); + return !!record && isAppToken(record) && record.app === app; +} + +async function presented(c: Context, storage: Storage): Promise { const token = bearer(c); - if (!token) return null; - const record = await storage.getDoc(docKeys.tokenByHash(await sha256Hex(token))); - if (!record || record.app !== app || record.pkg !== pkg) return null; - return record; + return token ? storage.getDoc(docKeys.tokenByHash(await sha256Hex(token))) : null; +} + +function isAppToken(record: TokenRecord | AppTokenRecord): record is AppTokenRecord { + return (record as AppTokenRecord).kind === "app"; +} + +export async function issueAppToken(storage: Storage, app: string): Promise<{ token: string; record: AppTokenRecord }> { + const { id, token, hash } = await freshToken(); + const record: AppTokenRecord = { kind: "app", id, app, createdAt: new Date().toISOString() }; + // the revocation reference first: if the second write fails, no live token exists that revocation cannot find + await storage.putDoc(docKeys.appTokenById(app, id), { hash }); + await storage.putDoc(docKeys.tokenByHash(hash), record); + return { token, record }; +} + +export async function revokeAppToken(storage: Storage, app: string, id: string): Promise { + const ref = await storage.getDoc<{ hash: string }>(docKeys.appTokenById(app, id)); + if (!ref) return false; + await storage.deleteDoc(docKeys.tokenByHash(ref.hash)); + await storage.deleteDoc(docKeys.appTokenById(app, id)); + return true; } /** A fresh token: `.`; the id is what revocation names, the whole string is what gets hashed. */ export async function issueToken(storage: Storage, app: string, pkg: string, channels: string[]): Promise<{ token: string; record: TokenRecord }> { - const id = hex(crypto.getRandomValues(new Uint8Array(6))); - const secret = base64url(crypto.getRandomValues(new Uint8Array(32))); - const token = `${id}.${secret}`; + const { id, token, hash } = await freshToken(); const record: TokenRecord = { id, app, pkg, channels, createdAt: new Date().toISOString() }; - const hash = await sha256Hex(token); - await storage.putDoc(docKeys.tokenByHash(hash), record); await storage.putDoc(docKeys.tokenById(app, pkg, id), { hash }); + await storage.putDoc(docKeys.tokenByHash(hash), record); return { token, record }; } @@ -62,6 +104,13 @@ export async function revokeToken(storage: Storage, app: string, pkg: string, id return true; } +async function freshToken(): Promise<{ id: string; token: string; hash: string }> { + // 128 bits: two live tokens sharing an id would leave the first one without a revocation reference + const id = hex(crypto.getRandomValues(new Uint8Array(16))); + const token = `${id}.${base64url(crypto.getRandomValues(new Uint8Array(32)))}`; + return { id, token, hash: await sha256Hex(token) }; +} + async function sha256Hex(text: string): Promise { return hex(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)))); } diff --git a/test/app-tokens.test.ts b/test/app-tokens.test.ts new file mode 100644 index 0000000..756784c --- /dev/null +++ b/test/app-tokens.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { ADMIN, newKeyPair, request, setup } from "./helpers.ts"; + +async function appToken(app: string): Promise<{ id: string; token: string }> { + const r = await request(`/apps/${app}/tokens`, { method: "POST", token: ADMIN }); + expect(r.status).toBe(201); + return (await r.json()) as { id: string; token: string }; +} + +describe("app tokens", () => { + it("are minted and revoked by the admin token only", async () => { + const { app } = await setup({ publicKey: (await newKeyPair()).publicKey }); + const { id, token } = await appToken(app); + // an app token cannot extend its own life: no minting peers, no revoking them + expect((await request(`/apps/${app}/tokens`, { method: "POST", token })).status).toBe(401); + expect((await request(`/apps/${app}/tokens/${id}`, { method: "DELETE", token })).status).toBe(401); + expect((await request(`/apps/nowhere/tokens`, { method: "POST", token: ADMIN })).status).toBe(404); + expect((await request(`/apps/${app}/tokens/${id}`, { method: "DELETE", token: ADMIN })).status).toBe(204); + expect((await request(`/apps/${app}/tokens/${id}`, { method: "DELETE", token: ADMIN })).status).toBe(404); + // revoked is gone at once + expect((await request(`/apps/${app}/packages`, { method: "POST", token, json: { name: "late", publicKey: (await newKeyPair()).publicKey } })).status).toBe(401); + }); + + it("manage their own app: packages, keys, publish tokens, releases", async () => { + const key = await newKeyPair(); + const { app, pkg } = await setup({ publicKey: key.publicKey }); + const { token } = await appToken(app); + expect((await request(`/apps/${app}/packages`, { method: "POST", token, json: { name: "orders", publicKey: key.publicKey } })).status).toBe(201); + expect((await request(`/apps/${app}/packages/orders/publicKey`, { method: "PUT", token, json: { publicKey: (await newKeyPair()).publicKey } })).status).toBe(200); + const issued = await request(`/apps/${app}/packages/${pkg}/tokens`, { method: "POST", token, json: { channels: ["staging"] } }); + expect(issued.status).toBe(201); + const { id } = (await issued.json()) as { id: string }; + expect((await request(`/apps/${app}/packages/${pkg}/tokens/${id}`, { method: "DELETE", token })).status).toBe(204); + expect((await request(`/${app}/${pkg}/1/releases`, { token })).status).toBe(200); + // it is not a publish token: publishing still takes the package's own token and the signed manifest + expect((await request(`/${app}/staging/${pkg}/1/current.json`, { method: "PUT", token, json: { version: "v", signature: "s" } })).status).toBe(401); + }); + + it("reach no other app, and create none", async () => { + const key = await newKeyPair(); + const mine = await setup({ publicKey: key.publicKey }); + const theirs = await setup({ publicKey: key.publicKey }); + const { token } = await appToken(mine.app); + expect((await request("/apps", { method: "POST", token, json: { id: "grabbed", name: "Grabbed" } })).status).toBe(401); + expect((await request(`/apps/${theirs.app}/packages`, { method: "POST", token, json: { name: "x", publicKey: key.publicKey } })).status).toBe(401); + expect((await request(`/apps/${theirs.app}/packages/${theirs.pkg}/tokens`, { method: "POST", token, json: { channels: ["production"] } })).status).toBe(401); + expect((await request(`/apps/${theirs.app}/hosts/1`, { method: "PUT", token, body: "host 1" })).status).toBe(401); + expect((await request(`/${theirs.app}/${theirs.pkg}/1/releases`, { token })).status).toBe(401); + // a publish token is not a manager either + expect((await request(`/apps/${mine.app}/packages`, { method: "POST", token: mine.token, json: { name: "x", publicKey: key.publicKey } })).status).toBe(401); + }); +}); + +describe("host snapshots", () => { + it("are written once per host version by the admin or an app token", async () => { + const { app, token: publishToken } = await setup({ publicKey: (await newKeyPair()).publicKey }); + const { token } = await appToken(app); + const put = await request(`/apps/${app}/hosts/1`, { method: "PUT", token, body: "hostVersion 1\ncapabilities\n checkout.start\n" }); + expect(put.status).toBe(201); + expect(await put.json()).toMatchObject({ hostVersion: "1", existing: false }); + const again = await request(`/apps/${app}/hosts/1`, { method: "PUT", token: ADMIN, body: "hostVersion 1\ncapabilities\n checkout.start\n" }); + expect(again.status).toBe(200); + expect(await again.json()).toMatchObject({ existing: true }); + // a shipped host version is frozen + const changed = await request(`/apps/${app}/hosts/1`, { method: "PUT", token, body: "hostVersion 1\ncapabilities\n coupon.apply\n" }); + expect(changed.status).toBe(409); + // a publish token reads them but cannot write them + expect((await request(`/apps/${app}/hosts/2`, { method: "PUT", token: publishToken, body: "x" })).status).toBe(401); + expect((await request(`/apps/${app}/hosts/2`, { method: "PUT", token, body: "" })).status).toBe(400); + expect((await request(`/apps/${app}/hosts/1.2.0`, { method: "PUT", token, body: "x" })).status).toBe(404); + }); + + it("are read back byte for byte by any token of the app, and by nobody else", async () => { + const key = await newKeyPair(); + const mine = await setup({ publicKey: key.publicKey }); + const theirs = await setup({ publicKey: key.publicKey }); + const snapshot = "hostVersion 3\ntinyui 0.3.0\ncomponents\n ta.Icon\n"; + expect((await request(`/apps/${mine.app}/hosts/3`, { method: "PUT", token: ADMIN, body: snapshot })).status).toBe(201); + const read = await request(`/apps/${mine.app}/hosts/3`, { token: mine.token }); + expect(read.status).toBe(200); + expect(await read.text()).toBe(snapshot); + expect((await request(`/apps/${mine.app}/hosts/3`, { token: (await appToken(mine.app)).token })).status).toBe(200); + expect((await request(`/apps/${mine.app}/hosts/3`, { token: theirs.token })).status).toBe(401); + expect((await request(`/apps/${mine.app}/hosts/3`)).status).toBe(401); + const missing = await request(`/apps/${mine.app}/hosts/4`, { token: mine.token }); + expect(missing.status).toBe(404); + expect(await missing.text()).toContain("has no snapshot"); + }); +}); diff --git a/test/publish.test.ts b/test/publish.test.ts index 86d3a20..909ba9c 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -224,7 +224,8 @@ describe("publishing and delivery", () => { expect((await request(`/${app}/${pkg}/1/../manifest.json`, { method: "PUT", token, body: "x" })).status).not.toBe(201); expect((await request(`/${app}/${pkg}/1/v1/..%2Fescape.bin`, { method: "PUT", token, body: "x" })).status).toBe(404); expect((await request(`/${app}/Prod/${pkg}/1/current.json`)).status).toBe(404); - expect((await request(`/apps/production/${pkg}/1/current.json`)).status).toBe(401); + // `apps` is reserved: never served as an app + expect((await request(`/apps/production/${pkg}/1/current.json`)).status).toBe(404); expect((await request(`/${app}/production/${pkg}/1/current.json`, { method: "PUT", token: ADMIN, json: {} })).status).toBe(401); }); });