diff --git a/README.md b/README.md index 2766d01..4d2553d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The community hub for the [AT Protocol](https://atproto.com/) ecosystem. Aggregates blog posts from [Offprint](https://blog.atmosphere.community), upcoming events from [Smoke Signal](https://smokesignal.events) and community accounts, and links to regional AT Protocol communities and apps. -Built with [Astro](https://astro.build/) and deployed to GitHub Pages. +Built with [Astro](https://astro.build/) and deployed to Dokploy through Tangled and GitHub workflows. ## Getting started @@ -15,20 +15,41 @@ The dev server starts at `localhost:4321`. The site fetches live data from ATPro ### Commands -| Command | Action | -| :---------------- | :------------------------------------------- | -| `npm install` | Install dependencies | -| `npm run dev` | Start local dev server at `localhost:4321` | -| `npm run build` | Build production site to `./dist/` | -| `npm run preview` | Preview the build locally before deploying | +| Command | Action | +| :---------------- | :---------------------------------------------------- | +| `npm install` | Install dependencies | +| `npm run dev` | Start local dev server at `localhost:4321` | +| `npm run verify` | Run Astro checks, strict TypeScript checks, and tests | +| `npm run build` | Build production site to `./dist/` | +| `npm run preview` | Preview the build locally before deploying | ### Deployment -The site deploys to GitHub Pages automatically via the workflow in `.github/workflows/deploy.yml`. It runs on: +The Tangled and GitHub workflows both verify and build the site, then trigger Dokploy. Deployment runs on: -- Push to `main` -- Every 6 hours (cron) to pick up new blog posts and events -- Manual trigger via `workflow_dispatch` +- Push to `main` on Tangled or GitHub +- Manual runs of the GitHub workflow + +OpenSocial join, leave, and sharing actions read secrets at runtime, so configure these variables in the deployed server environment, not only in the build workflows: + +```sh +OPENSOCIAL_CIMD_PRIVATE_KEY_BASE64= +OPENSOCIAL_SIGNATURE_KEY_ID= +# Optional; generated by the key script and defaults to opensocial-cimd-1 +OPENSOCIAL_CIMD_KID= +# Optional override; defaults to https://api.opensocial.community +OPENSOCIAL_SERVICE= +``` + +`OPENSOCIAL_CIMD_PRIVATE_KEY_PEM` is supported as an alternative to the base64 +private key. Existing deployments may continue to use `OPENSOCIAL_APP_ID` as a +legacy alias for `OPENSOCIAL_SIGNATURE_KEY_ID`. + +Generate a signing key with: + +```sh +node scripts/generate-cimd-key.mjs +``` ## Project structure diff --git a/src/lib/cmid-signing/index.ts b/src/lib/cmid-signing/index.ts index adaefeb..60e349f 100644 --- a/src/lib/cmid-signing/index.ts +++ b/src/lib/cmid-signing/index.ts @@ -1,4 +1,48 @@ -export { - getPublicJwk, - signRequest, +import type { KeyObject } from "node:crypto"; +import { getSecret } from "astro:env/server"; + +import { + createPublicJwk, + parsePrivateKey, + signRequest as signRequestWithKey, } from "./keys.js"; + +const DEFAULT_KID = "opensocial-cimd-1"; + +let cachedPrivate: KeyObject | null = null; +function getPrivateKey(): KeyObject { + if (!cachedPrivate) { + cachedPrivate = parsePrivateKey({ + base64: getSecret("OPENSOCIAL_CIMD_PRIVATE_KEY_BASE64"), + pem: getSecret("OPENSOCIAL_CIMD_PRIVATE_KEY_PEM"), + }); + } + return cachedPrivate; +} + +let cachedPublicJwk: ReturnType | null = null; +export function getPublicJwk(): ReturnType { + if (!cachedPublicJwk) { + cachedPublicJwk = createPublicJwk({ + privateKey: getPrivateKey(), + kid: getSecret("OPENSOCIAL_CIMD_KID") || DEFAULT_KID, + }); + } + return cachedPublicJwk; +} + +export function signRequest(opts: { + method: string; + url: string; + body?: string | null; + keyId: string; + /** Override clock for tests. Seconds since epoch. */ + nowSeconds?: number; + /** Override the private key for tests. Defaults to the configured server key. */ + privateKey?: KeyObject; +}) { + return signRequestWithKey({ + ...opts, + privateKey: opts.privateKey ?? getPrivateKey(), + }); +} diff --git a/src/lib/cmid-signing/keys.test.ts b/src/lib/cmid-signing/keys.test.ts new file mode 100644 index 0000000..8534941 --- /dev/null +++ b/src/lib/cmid-signing/keys.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict' +import { generateKeyPairSync } from 'node:crypto' +import test from 'node:test' + +import { + createPublicJwk, + parsePrivateKey, + signRequest, +} from './keys.js' + +test('signRequest labels the signing identity and covers request bodies', () => { + const { privateKey } = generateKeyPairSync('ed25519') + const headers = signRequest({ + method: 'POST', + url: 'https://api.example/xrpc/community.opensocial.joinCommunity', + body: '{"community":"did:plc:example"}', + keyId: 'atmosphere.community', + nowSeconds: 1_721_600_000, + privateKey, + }) + + assert.equal( + headers['Signature-Input'], + 'sig1=("@method" "@target-uri" "content-digest");created=1721600000;keyid="atmosphere.community"', + ) + assert.match(headers.Signature, /^sig1=:[A-Za-z0-9+/]+=*:$/) + assert.match( + headers['Content-Digest'] ?? '', + /^sha-256=:[A-Za-z0-9+/]+=*:$/, + ) +}) + +test('signRequest omits the content digest for GET requests', () => { + const { privateKey } = generateKeyPairSync('ed25519') + const headers = signRequest({ + method: 'GET', + url: 'https://api.example/xrpc/community.opensocial.getPermissions', + keyId: 'atmosphere.community', + nowSeconds: 1_721_600_000, + privateKey, + }) + + assert.equal(headers['Content-Digest'], undefined) + assert.equal( + headers['Signature-Input'], + 'sig1=("@method" "@target-uri");created=1721600000;keyid="atmosphere.community"', + ) +}) + +test('parsePrivateKey accepts generated base64 and escaped PEM values', () => { + const { privateKey } = generateKeyPairSync('ed25519') + const pem = privateKey.export({ format: 'pem', type: 'pkcs8' }).toString() + const base64 = Buffer.from(pem, 'utf8').toString('base64') + + assert.equal( + parsePrivateKey({ base64 }) + .export({ format: 'pem', type: 'pkcs8' }) + .toString(), + pem, + ) + assert.equal( + parsePrivateKey({ pem: pem.replaceAll('\n', '\\n') }) + .export({ format: 'pem', type: 'pkcs8' }) + .toString(), + pem, + ) +}) + +test('createPublicJwk keeps the configured JWK kid', () => { + const { privateKey } = generateKeyPairSync('ed25519') + const jwk = createPublicJwk({ privateKey, kid: 'opensocial-cimd-test' }) + + assert.equal(jwk.kid, 'opensocial-cimd-test') + assert.equal(jwk.use, 'sig') + assert.equal(jwk.kty, 'OKP') + assert.equal(jwk.crv, 'Ed25519') +}) diff --git a/src/lib/cmid-signing/keys.ts b/src/lib/cmid-signing/keys.ts index ce5cee9..6b1a0e6 100644 --- a/src/lib/cmid-signing/keys.ts +++ b/src/lib/cmid-signing/keys.ts @@ -6,16 +6,20 @@ import { type KeyObject, } from "node:crypto"; -const DEFAULT_KID = "opensocial-cimd-1"; - -function loadPrivatePem(): string { - const b64 = import.meta.env.OPENSOCIAL_CIMD_PRIVATE_KEY_BASE64; - if (b64 && b64.length > 0) { - return Buffer.from(b64, "base64").toString("utf-8"); +export function parsePrivateKey({ + base64, + pem, +}: { + base64?: string; + pem?: string; +}): KeyObject { + if (base64 && base64.length > 0) { + return createPrivateKey(Buffer.from(base64, "base64").toString("utf-8")); } - const pem = import.meta.env.OPENSOCIAL_CIMD_PRIVATE_KEY_PEM; if (pem && pem.length > 0) { - return pem.includes("\\n") ? pem.replaceAll("\\n", "\n") : pem; + return createPrivateKey( + pem.includes("\\n") ? pem.replaceAll("\\n", "\n") : pem, + ); } throw new Error( "OPENSOCIAL_CIMD_PRIVATE_KEY_BASE64 (or _PEM) not set. " + @@ -23,44 +27,40 @@ function loadPrivatePem(): string { ); } -let cachedPrivate: KeyObject | null = null; -function getPrivateKey(): KeyObject { - if (!cachedPrivate) cachedPrivate = createPrivateKey(loadPrivatePem()); - return cachedPrivate; -} - -let cachedPublicJwk: { +export function createPublicJwk({ + privateKey, + kid, +}: { + privateKey: KeyObject; + kid: string; +}): { kty: string; crv?: string; x?: string; kid: string; use: "sig"; -} | null = null; -export function getPublicJwk() { - if (cachedPublicJwk) return cachedPublicJwk; - const pub = createPublicKey(getPrivateKey()); +} { + const pub = createPublicKey(privateKey); const jwk = pub.export({ format: "jwk" }) as { kty: string; crv?: string; x?: string; }; - cachedPublicJwk = { + return { ...jwk, - kid: import.meta.env.OPENSOCIAL_CIMD_KID || DEFAULT_KID, + kid, use: "sig", }; - return cachedPublicJwk; } export function signRequest(opts: { method: string; url: string; body?: string | null; - appId: string; + keyId: string; + privateKey: KeyObject; /** Override clock for tests. Seconds since epoch. */ nowSeconds?: number; - /** Override the private key for tests. Defaults to getPrivateKey(). */ - privateKey?: KeyObject; }) { const method = opts.method.toUpperCase(); const created = opts.nowSeconds ?? Math.floor(Date.now() / 1000); @@ -82,13 +82,13 @@ export function signRequest(opts: { lines.push(`"content-digest": ${digestValue}`); } - const signatureParams = `(${components.join(" ")});created=${created};keyid="${opts.appId}"`; + const signatureParams = `(${components.join(" ")});created=${created};keyid="${opts.keyId}"`; lines.push(`"@signature-params": ${signatureParams}`); const signature = cryptoSign( null, Buffer.from(lines.join("\n"), "utf-8"), - opts.privateKey ?? getPrivateKey(), + opts.privateKey, ); return { diff --git a/src/lib/opensocial/client.ts b/src/lib/opensocial/client.ts index 3c3d039..bb27a46 100644 --- a/src/lib/opensocial/client.ts +++ b/src/lib/opensocial/client.ts @@ -1,4 +1,5 @@ import { XrpcError, isDidString } from "@atproto/lex"; +import { getSecret } from "astro:env/server"; import { createSignedLexClient } from "./xrpc.js"; @@ -17,16 +18,18 @@ export class OpenSocialCommunityError extends Error { } export function createOpenSocialClient() { - const appId = import.meta.env.OPENSOCIAL_APP_ID; - if (!appId || appId.length === 0) { + const keyId = + getSecret("OPENSOCIAL_SIGNATURE_KEY_ID") || + getSecret("OPENSOCIAL_APP_ID"); + if (!keyId) { throw new Error( - "OPENSOCIAL_APP_ID is not set; cannot sign opensocial requests", + "OPENSOCIAL_SIGNATURE_KEY_ID is not set; cannot sign OpenSocial requests", ); } return createSignedLexClient({ - service: import.meta.env.OPENSOCIAL_SERVICE || DEFAULT_SERVICE, - appId, + service: getSecret("OPENSOCIAL_SERVICE") || DEFAULT_SERVICE, + keyId, }); } diff --git a/src/lib/opensocial/xrpc.ts b/src/lib/opensocial/xrpc.ts index c3f49b6..76fd5dc 100644 --- a/src/lib/opensocial/xrpc.ts +++ b/src/lib/opensocial/xrpc.ts @@ -9,8 +9,8 @@ import { signRequest } from "../cmid-signing/index.js"; export function createSignedLexClient(opts: { /** Service origin for XRPC calls, for example `https://api.example.com`. */ service: string | URL; - /** Registered app id, used as the `keyid` in HTTP signatures. */ - appId: string; + /** OpenSocial client identifier serialized as `keyid` in HTTP signatures. */ + keyId: string; /** Override for tests. Defaults to global `fetch`. */ fetchImpl?: typeof fetch; /** Defaults to false so callers can start without generated schemas. */ @@ -25,7 +25,7 @@ export function createSignedLexClient(opts: { method: request.method, url: request.url, body: request.body, - appId: opts.appId, + keyId: opts.keyId, }); }, }),