Skip to content
Closed
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
91 changes: 91 additions & 0 deletions docs/enterprise-remote-signer-api.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 4 additions & 13 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down Expand Up @@ -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"
}
}
73 changes: 73 additions & 0 deletions web/src/shared/lib/enterprise-signer.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading