diff --git a/docs/enterprise-remote-signer-api.md b/docs/enterprise-remote-signer-api.md new file mode 100644 index 00000000000..6d18e5d8314 --- /dev/null +++ b/docs/enterprise-remote-signer-api.md @@ -0,0 +1,91 @@ +# Enterprise remote signer API (draft) + +This public contract intentionally omits Block-internal service names and storage details. + +Enterprise Buzz builds MAY opt into a corporate-authoritative signer by setting `VITE_ENTERPRISE_SIGNER_BASE_URL` (web) or the platform-equivalent managed configuration. OSS/self-custody builds leave it unset and continue to use local NIP-07/local keys. + +All requests use HTTPS, include the corporate Auth0 access/session credential accepted by the enterprise backend, and fail closed on missing, expired, or disabled corporate access. The server chooses the signer key from the authenticated stable corporate account (`issuer` + `subject`, or the existing documented stable internal account id derived from it). Clients MUST NOT send a pubkey/nsec/key selector. + +## POST /v1/buzz/enterprise-signer/session + +Returns signer configuration for the authenticated account and provisions/repairs relay community membership durably. + +Request: `{}` + +Response: + +```json +{ + "pubkeyHex": "32-byte lowercase hex", + "relayWsUrl": "wss://community.example", + "relayHttpUrl": "https://community.example", + "communityId": "optional durable community id", + "membershipState": "active|pending", + "retryAfterMs": 1000 +} +``` + +`pending` means the server has accepted the login and queued durable provisioning, but the client should not assume relay admission yet. + +## POST /v1/buzz/enterprise-signer/events/sign + +Signs one Nostr event template as the authenticated corporate account. Used for read path NIP-42 challenges and direct client publishes. + +Request: + +```json +{ + "event": { "kind": 22242, "created_at": 0, "tags": [], "content": "" }, + "purpose": "nip42-auth|publish|http-auth|media-upload" +} +``` + +Response: + +```json +{ "event": { "id": "...", "pubkey": "server account pubkey", "sig": "...", "kind": 22242, "created_at": 0, "tags": [], "content": "" } } +``` + +Server requirements: +- derive `pubkey` server-side; reject any supplied `pubkey`, `id`, or `sig` +- validate `purpose`, kind, timestamps, tag cardinality, relay URL/challenge binding for NIP-42, media hash/host binding for uploads, and request size/time bounds +- do not log tokens, private keys, signatures containing bearer material, or event content beyond explicitly safe metadata + +## POST /v1/buzz/enterprise-signer/events/publish + +Durably signs and publishes a template, or replays the identical signed event/ack for the same idempotency key. This endpoint is preferred where the client cannot safely preserve retry identity. + +Request: + +```json +{ + "idempotencyKey": "client-stable retry key", + "event": { "kind": 1, "created_at": 0, "tags": [], "content": "..." } +} +``` + +Response: + +```json +{ + "event": { "id": "...", "pubkey": "server account pubkey", "sig": "..." }, + "relayAck": { "accepted": true, "message": "" }, + "state": "published|pending|indeterminate" +} +``` + +## POST /v1/buzz/enterprise-signer/media/read-credential + +Returns short-lived, host-scoped read credentials for Buzz media. Clients cache only until `expiresAt` and only for the returned host. + +Request: `{ "host": "media/community host" }` + +Response: `{ "authorization": "Bearer ...", "expiresAt": "2026-09-11T00:00:00Z" }` + +## Offboarding + +Disabling the corporate account must disable signer access and remove/disable community membership. Active WebSocket disconnect is intentionally outside this PR and depends on the separate active-connection revocation branch. Token revocation is bounded by the backend's Auth0/session validation and any already-issued short-lived media credential expiry. + +## Known unsupported operations in this proof + +NIP-46 is optional and not required. End-to-end encrypted DM/NIP-44 operations that require client-side private-key access remain local/self-custody only until the product defines a server-side encryption authority model; enterprise clients must not silently export nsecs or claim encrypted-DM support through this signer. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8476d3dd321..e11a9048e18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -404,6 +404,9 @@ importers: vite: specifier: ^8.0.0 version: 8.0.16(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0) + vitest: + specifier: ^4.1.11 + version: 4.1.11(@types/node@25.6.0)(jsdom@27.4.0(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0)) packages: @@ -543,28 +546,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.16': resolution: {integrity: sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.16': resolution: {integrity: sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.4.16': resolution: {integrity: sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.16': resolution: {integrity: sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==} @@ -1507,7 +1506,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.3': resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} @@ -1521,14 +1519,12 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.3': resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.3': resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} @@ -1658,21 +1654,18 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -1824,7 +1817,6 @@ packages: engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.11.4': resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==} @@ -1838,7 +1830,6 @@ packages: engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.11.4': resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==} @@ -3716,7 +3707,7 @@ packages: engines: {node: '>=8'} wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=} ws@8.21.1: resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} diff --git a/web/package.json b/web/package.json index d932a612c8c..4f2f7d67fe4 100644 --- a/web/package.json +++ b/web/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc && vite build", "typecheck": "tsc --noEmit", + "test": "vitest run", "check:file-sizes": "node ./scripts/check-file-sizes.mjs", "check:pubkey-truncation": "node ./scripts/check-pubkey-truncation.mjs", "lint": "biome lint .", @@ -49,6 +50,7 @@ "tailwindcss": "^4.3.0", "tw-animate-css": "^1.4.0", "typescript": "~6.0.0", - "vite": "^8.0.0" + "vite": "^8.0.0", + "vitest": "^4.1.11" } } diff --git a/web/src/shared/lib/enterprise-signer.test.ts b/web/src/shared/lib/enterprise-signer.test.ts new file mode 100644 index 00000000000..726175ff1ef --- /dev/null +++ b/web/src/shared/lib/enterprise-signer.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { + getEnterpriseSignerSession, + signWithEnterpriseSigner, +} from "./enterprise-signer"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.unstubAllEnvs(); +}); + +describe("enterprise signer", () => { + it("is disabled unless explicitly configured", async () => { + await expect(getEnterpriseSignerSession()).rejects.toThrow( + "not configured", + ); + }); + + it("rejects caller-supplied key selectors", async () => { + vi.stubEnv("VITE_ENTERPRISE_SIGNER_BASE_URL", "https://signer.example"); + await expect( + signWithEnterpriseSigner( + { + kind: 1, + created_at: 1, + tags: [], + content: "hi", + pubkey: "00", + } as never, + "publish", + ), + ).rejects.toThrow("must not include pubkey"); + }); + + it("requires the signed event to use the server account key", async () => { + vi.stubEnv("VITE_ENTERPRISE_SIGNER_BASE_URL", "https://signer.example"); + const account = "a".repeat(64); + const wrong = "b".repeat(64); + globalThis.fetch = vi.fn(async (_url, init) => { + const path = String(_url); + if (path.endsWith("/session")) { + return new Response( + JSON.stringify({ pubkeyHex: account, membershipState: "active" }), + { status: 200 }, + ); + } + expect(JSON.parse(String(init?.body)).event.pubkey).toBeUndefined(); + return new Response( + JSON.stringify({ + event: { + kind: 1, + created_at: 1, + tags: [], + content: "hi", + id: "id", + pubkey: wrong, + sig: "sig", + }, + }), + { status: 200 }, + ); + }) as never; + + await expect( + signWithEnterpriseSigner( + { kind: 1, created_at: 1, tags: [], content: "hi" }, + "publish", + ), + ).rejects.toThrow("wrong account"); + }); +}); diff --git a/web/src/shared/lib/enterprise-signer.ts b/web/src/shared/lib/enterprise-signer.ts new file mode 100644 index 00000000000..e2add4a7fd5 --- /dev/null +++ b/web/src/shared/lib/enterprise-signer.ts @@ -0,0 +1,118 @@ +import type { SignedNostrEvent, UnsignedNostrEvent } from "./nostr-signer"; + +export type EnterpriseSignerSession = { + pubkeyHex: string; + relayWsUrl?: string; + relayHttpUrl?: string; + communityId?: string; + membershipState?: "active" | "pending"; + retryAfterMs?: number; +}; + +type SignPurpose = "nip42-auth" | "publish" | "http-auth" | "media-upload"; + +const DEFAULT_TIMEOUT_MS = 10_000; +const HEX_32 = /^[0-9a-f]{64}$/; + +export function enterpriseSignerBaseUrl(): string | null { + const configured = import.meta.env.VITE_ENTERPRISE_SIGNER_BASE_URL?.trim(); + if (!configured) return null; + return configured.replace(/\/+$/, ""); +} + +export function isEnterpriseSignerEnabled(): boolean { + return enterpriseSignerBaseUrl() != null; +} + +export class EnterpriseSignerError extends Error { + constructor(message: string) { + super(message); + this.name = "EnterpriseSignerError"; + } +} + +async function postJson(path: string, body: unknown): Promise { + const base = enterpriseSignerBaseUrl(); + if (!base) + throw new EnterpriseSignerError("Enterprise signer is not configured."); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS); + try { + const response = await fetch(`${base}${path}`, { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + signal: controller.signal, + }); + if (!response.ok) { + throw new EnterpriseSignerError( + `Enterprise signer request failed (${response.status}).`, + ); + } + return (await response.json()) as T; + } finally { + clearTimeout(timeout); + } +} + +function assertUnsignedOnly(event: UnsignedNostrEvent): void { + const maybeSigned = event as UnsignedNostrEvent & Partial; + if (maybeSigned.pubkey || maybeSigned.id || maybeSigned.sig) { + throw new EnterpriseSignerError( + "Enterprise signer templates must not include pubkey, id, or sig.", + ); + } +} + +export async function getEnterpriseSignerSession(): Promise { + const session = await postJson( + "/v1/buzz/enterprise-signer/session", + {}, + ); + if (!HEX_32.test(session.pubkeyHex)) { + throw new EnterpriseSignerError( + "Enterprise signer returned an invalid pubkey.", + ); + } + return session; +} + +export async function signWithEnterpriseSigner( + event: UnsignedNostrEvent, + purpose: SignPurpose, +): Promise { + assertUnsignedOnly(event); + const session = await getEnterpriseSignerSession(); + if (session.membershipState === "pending") { + throw new EnterpriseSignerError( + "Enterprise relay membership is still provisioning.", + ); + } + const response = await postJson<{ event: SignedNostrEvent }>( + "/v1/buzz/enterprise-signer/events/sign", + { + event, + purpose, + }, + ); + const signed = response.event; + if (signed.pubkey !== session.pubkeyHex) { + throw new EnterpriseSignerError( + "Enterprise signer returned an event for the wrong account.", + ); + } + if ( + signed.kind !== event.kind || + signed.created_at !== event.created_at || + signed.content !== event.content || + JSON.stringify(signed.tags) !== JSON.stringify(event.tags) || + !signed.id || + !signed.sig + ) { + throw new EnterpriseSignerError( + "Enterprise signer returned an invalid signed event.", + ); + } + return signed; +} diff --git a/web/src/shared/lib/nostr-signer.ts b/web/src/shared/lib/nostr-signer.ts index a32d3a680b8..dd528f3708e 100644 --- a/web/src/shared/lib/nostr-signer.ts +++ b/web/src/shared/lib/nostr-signer.ts @@ -3,6 +3,10 @@ import { generateSecretKey, getPublicKey, } from "nostr-tools/pure"; +import { + isEnterpriseSignerEnabled, + signWithEnterpriseSigner, +} from "./enterprise-signer"; export type UnsignedNostrEvent = { kind: number; @@ -79,6 +83,17 @@ export async function signNostrEvent( }; const provider = typeof window === "undefined" ? undefined : window.nostr; + if (isEnterpriseSignerEnabled()) { + return signWithEnterpriseSigner( + unsigned, + unsigned.kind === 22242 + ? "nip42-auth" + : unsigned.kind === 27235 + ? "http-auth" + : "publish", + ); + } + if (provider) { const expectedPubkey = await provider.getPublicKey(); const signed = await provider.signEvent(unsigned); diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts index 11f02fe2a00..6b1186f0566 100644 --- a/web/src/vite-env.d.ts +++ b/web/src/vite-env.d.ts @@ -1 +1,5 @@ /// + +interface ImportMetaEnv { + readonly VITE_ENTERPRISE_SIGNER_BASE_URL?: string; +}