Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions apps/api/src/hosted/site-token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from "bun:test";
import { memoryKv } from "./hosted-test-helpers";
import { createSiteTokens, importSigningKey } from "./site-token";

async function keyPair() {
const pair = (await crypto.subtle.generateKey({ name: "Ed25519" }, true, [
"sign",
"verify",
])) as CryptoKeyPair;
const pkcs8 = Buffer.from(
await crypto.subtle.exportKey("pkcs8", pair.privateKey),
).toString("base64");
return { pkcs8, publicKey: pair.publicKey };
}

function setup(pkcs8: string) {
return createSiteTokens({
kv: memoryKv(),
signingKey: () => importSigningKey(pkcs8),
});
}

describe("site tokens", () => {
it("issues an EdDSA JWS of {site, kid, iat} the edge can verify", async () => {
const { pkcs8, publicKey } = await keyPair();
const tokens = setup(pkcs8);
const { token, record } = await tokens.issue("org", "acme");
const [h, p, sig] = token.split(".");
expect(JSON.parse(Buffer.from(h!, "base64url").toString())).toEqual({
alg: "EdDSA",
typ: "JWT",
});
expect(JSON.parse(Buffer.from(p!, "base64url").toString())).toEqual({
site: "acme",
kid: record.kid,
iat: record.iat,
});
expect(
await crypto.subtle.verify(
{ name: "Ed25519" },
publicKey,
Buffer.from(sig!, "base64url"),
new TextEncoder().encode(`${h}.${p}`),
),
).toBe(true);
expect(await tokens.list("org", "acme")).toEqual([record]);
expect(JSON.stringify(await tokens.list("org", "acme"))).not.toContain(
sig!,
);
});

it("issues as many tokens as asked, each listed by kid", async () => {
const { pkcs8 } = await keyPair();
const tokens = setup(pkcs8);
const issued = [];
for (let i = 0; i < 4; i++) issued.push(await tokens.issue("org", "acme"));
expect(new Set(issued.map((i) => i.record.kid)).size).toBe(4);
expect(await tokens.list("org", "acme")).toEqual(
issued.map((i) => i.record),
);
expect(await tokens.list("org", "other")).toEqual([]);
});
});
103 changes: 103 additions & 0 deletions apps/api/src/hosted/site-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* Site tokens: what a hosted site passes as `createCMS({ token })` to send
* telemetry. A token is a JWS (compact JWT) signed by Studio with Ed25519:
*
* header {"alg":"EdDSA","typ":"JWT"}
* payload {"site":"<site>","kid":"<token id>","iat":<unix seconds>}
*
* No expiry and no revocation: the edge checks only the signature, and stamps
* the telemetry with the token's site. Issuing always works; Studio lists the
* tokens it issued (kid and time). The token itself is shown once and never
* stored.
*/

import type { KVStorage } from "@/storage/kv";

export interface SiteTokenRecord {
kid: string;
/** Issued at, Unix seconds (the token's `iat`). */
iat: number;
}

// OPEN: O-S2 — token records live in the org KV per site; no migration.
function recordsKey(site: string): string {
return `site-tokens:${site}`;
}

function parseRecords(
value: Record<string, unknown> | null,
): SiteTokenRecord[] {
const tokens = value?.tokens;
if (!Array.isArray(tokens)) return [];
return tokens.filter(
(t): t is SiteTokenRecord =>
typeof t === "object" &&
t !== null &&
typeof (t as SiteTokenRecord).kid === "string" &&
typeof (t as SiteTokenRecord).iat === "number",
);
}

const b64u = (bytes: Uint8Array | ArrayBuffer) =>
Buffer.from(
bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes),
).toString("base64url");

// OPEN: O-S6 — the signing key is base64 PKCS8 (`openssl genpkey -algorithm ed25519`).
export function importSigningKey(base64Pkcs8: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
"pkcs8",
Buffer.from(base64Pkcs8, "base64"),
{ name: "Ed25519" },
false,
["sign"],
);
}

async function signSiteToken(
key: CryptoKey,
payload: { site: string; kid: string; iat: number },
): Promise<string> {
const encode = (value: unknown) =>
b64u(new TextEncoder().encode(JSON.stringify(value)));
const signingInput = `${encode({ alg: "EdDSA", typ: "JWT" })}.${encode({
site: payload.site,
kid: payload.kid,
iat: payload.iat,
})}`;
const signature = await crypto.subtle.sign(
{ name: "Ed25519" },
key,
new TextEncoder().encode(signingInput),
);
return `${signingInput}.${b64u(signature)}`;
}

export function createSiteTokens(deps: {
kv: KVStorage;
signingKey: () => Promise<CryptoKey>;
}) {
const list = async (organizationId: string, site: string) =>
parseRecords(await deps.kv.get(organizationId, recordsKey(site)));

return {
list,

async issue(organizationId: string, site: string) {
const records = await list(organizationId, site);
// OPEN: O-15 — kid is this token's id: 16 random bytes, base64url.
const record: SiteTokenRecord = {
kid: b64u(crypto.getRandomValues(new Uint8Array(16))),
iat: Math.floor(Date.now() / 1000),
};
const token = await signSiteToken(await deps.signingKey(), {
site,
...record,
});
await deps.kv.set(organizationId, recordsKey(site), {
tokens: [...records, record],
});
return { token, record };
},
};
}
Loading