openapi: 3.1.0 info: title: Shade Prekey Server description: | Signal Protocol prekey distribution server. Stores identity public keys and prekey bundles. Any language can implement a client — this spec documents the wire contract. **Security model:** Write operations (register, replenish, delete) are authenticated by Ed25519 signatures over the request body. Bundle fetches are anonymous. See the `SignedPayload` schema for the signing format. version: "4.0.0" license: name: MIT url: https://gt.zyon.no/Stian/Shade/raw/branch/main/LICENSE servers: - url: https://shade.example.com description: Replace with your project's prekey server URL paths: /health: get: summary: Health check description: Returns 200 if the server and its storage backend are reachable. tags: [Infrastructure] responses: '200': description: Healthy content: application/json: schema: $ref: '#/components/schemas/HealthResponse' '503': description: Unhealthy /metrics: get: summary: Prometheus metrics description: Counter, histogram, and gauge metrics in Prometheus text format. tags: [Infrastructure] responses: '200': description: Metrics content: text/plain: schema: type: string example: | # HELP shade_requests_total Total HTTP requests # TYPE shade_requests_total counter shade_requests_total{route="/v1/keys/register",status="200"} 42 /openapi.yaml: get: summary: This OpenAPI spec description: Serves the YAML spec itself, so clients can auto-download it. tags: [Infrastructure] responses: '200': description: OpenAPI spec content: application/yaml: schema: type: string /v1/keys/register: post: summary: Register identity and upload prekey bundle description: | Register a new identity with the prekey server, or update an existing one. The request body must be signed with the identity's own Ed25519 signing key (TOFU — first signature establishes the identity). tags: [Keys] security: - Ed25519Signature: [] requestBody: required: true content: application/json: schema: allOf: - $ref: '#/components/schemas/SignedPayload' - type: object required: [address, identitySigningKey, identityDHKey, signedPreKey] properties: address: $ref: '#/components/schemas/Address' identitySigningKey: type: string description: Ed25519 public key, base64 identityDHKey: type: string description: X25519 public key, base64 signedPreKey: $ref: '#/components/schemas/SignedPreKeyEntry' oneTimePreKeys: type: array items: $ref: '#/components/schemas/OneTimePreKeyEntry' responses: '200': description: Registered content: application/json: schema: type: object properties: ok: { type: boolean } '400': $ref: '#/components/responses/ValidationError' '401': $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/RateLimited' /v1/keys/bundle/{address}: get: summary: Fetch a prekey bundle (anonymous) description: | Fetch an identity's prekey bundle so Alice can start an X3DH session with Bob. Each call consumes one one-time prekey (FIFO) if any are available — if not, returns a bundle without the one-time prekey. tags: [Keys] parameters: - name: address in: path required: true schema: $ref: '#/components/schemas/Address' responses: '200': description: Prekey bundle content: application/json: schema: $ref: '#/components/schemas/PreKeyBundle' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' /v1/keys/count/{address}: get: summary: Get remaining one-time prekey count (anonymous) tags: [Keys] parameters: - name: address in: path required: true schema: $ref: '#/components/schemas/Address' responses: '200': description: Count content: application/json: schema: type: object properties: count: { type: integer, minimum: 0 } /v1/keys/replenish: post: summary: Upload more one-time prekeys (signed) tags: [Keys] security: - Ed25519Signature: [] requestBody: required: true content: application/json: schema: allOf: - $ref: '#/components/schemas/SignedPayload' - type: object required: [address, oneTimePreKeys] properties: address: $ref: '#/components/schemas/Address' oneTimePreKeys: type: array items: $ref: '#/components/schemas/OneTimePreKeyEntry' responses: '200': description: Replenished content: application/json: schema: type: object properties: ok: { type: boolean } remaining: { type: integer, minimum: 0 } '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' /v1/keys/{address}: delete: summary: Unregister an identity (signed) tags: [Keys] security: - Ed25519Signature: [] parameters: - name: address in: path required: true schema: $ref: '#/components/schemas/Address' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SignedPayload' responses: '200': description: Deleted '401': $ref: '#/components/responses/Unauthorized' /v1/kt/log_id: get: summary: Key Transparency log identity (V3.12) description: | Returns the operator's STH-signing public key and derived log_id (`SHA-256(public_key)`). Clients fetch this once during bootstrap to confirm they pinned the right key out-of-band. Available when the server is started with `keyTransparency` configured. tags: [KeyTransparency] responses: '200': description: Log identity content: application/json: schema: type: object required: [logId, publicKey] properties: logId: { type: string, description: 'base64 of sha256(publicKey)' } publicKey: { type: string, description: 'base64 Ed25519 public key (32 bytes)' } /v1/kt/sth: get: summary: Latest Signed Tree Head (V3.12) tags: [KeyTransparency] responses: '200': description: Most recent STH content: application/json: schema: $ref: '#/components/schemas/STH' /v1/kt/sth/{treeSize}: get: summary: Historical STH at a specific tree size (V3.12) tags: [KeyTransparency] parameters: - name: treeSize in: path required: true schema: type: integer minimum: 0 responses: '200': description: STH at requested tree_size content: application/json: schema: $ref: '#/components/schemas/STH' '404': description: No STH at that tree_size /v1/kt/consistency: get: summary: Consistency proof between two tree sizes (V3.12) description: | RFC 6962 §2.1.2 consistency proof from `from` to `to`. Used by clients and witnesses to verify that the log between two STHs is an honest extension, not a re-write. tags: [KeyTransparency] parameters: - name: from in: query required: true schema: { type: integer, minimum: 0 } - name: to in: query required: false schema: { type: integer, minimum: 0 } responses: '200': description: Consistency proof content: application/json: schema: type: object required: [fromTreeSize, toTreeSize, proof] properties: fromTreeSize: { type: integer } toTreeSize: { type: integer } proof: type: array items: { type: string, description: 'base64 of 32-byte node hash' } /v1/transfer/health: get: summary: Peer transfer-route reachability probe description: | Lightweight probe served by **the receiver-side peer** (mounted via `Shade.transferRoute()` or `createTransferRoutes()`). The prekey server itself does not serve `/v1/transfer/*` — it is documented here so any-language clients can generate transfer clients from the same OpenAPI contract. tags: [Transfer] responses: '200': description: Peer is reachable content: application/json: schema: type: object required: [ok] properties: ok: { type: boolean } /v1/transfer/{streamId}/chunk: post: summary: Upload a stream-chunk envelope (signed by sender) description: | Per-chunk POST: the sender uploads a wire-encoded `0x11` stream-chunk envelope, signed via `ShadeTransferAuthenticator`. The receiver verifies the signature, ratifies lane + seq, and returns an `{ok, lastSeqAcked}` ACK. Body is `application/octet-stream`. Body size is bounded by the receiver's `maxChunkBytes` option (default ≈ 16 MiB + header); anything over the limit returns `413` before decryption. tags: [Transfer] security: - ShadeTransferAuthenticator: [] parameters: - name: streamId in: path required: true schema: $ref: '#/components/schemas/StreamId' - name: X-Shade-Lane-Id in: header required: true description: Lane index (`u32`, 0-based) the chunk belongs to. schema: type: integer minimum: 0 - name: X-Shade-Seq in: header required: true description: Per-lane chunk sequence number (`u64` decimal). schema: type: string pattern: '^[0-9]+$' requestBody: required: true content: application/octet-stream: schema: type: string format: binary description: Wire-encoded `0x11` stream-chunk envelope. responses: '200': description: Chunk accepted content: application/json: schema: $ref: '#/components/schemas/ChunkAck' '400': $ref: '#/components/responses/ValidationError' '401': $ref: '#/components/responses/Unauthorized' '409': description: Replay or out-of-order chunk content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '413': description: Chunk exceeds receiver's `maxChunkBytes` content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' /v1/transfer/{streamId}/state: get: summary: Read resume-state for a stream (signed) description: | Sender-side resume probe: returns the receiver's per-lane `lastSeqAcked` so the sender can skip already-ack'd chunks. Authentication is the same `ShadeTransferAuthenticator` shape as `/chunk`, but signed over the control canonical form (method + path) rather than chunk bodies. tags: [Transfer] security: - ShadeTransferAuthenticator: [] parameters: - name: streamId in: path required: true schema: $ref: '#/components/schemas/StreamId' responses: '200': description: Resume state content: application/json: schema: $ref: '#/components/schemas/ResumeState' '401': $ref: '#/components/responses/Unauthorized' '404': description: No state recorded for this streamId content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' /v1/transfer/control: post: summary: Deliver a control-plane ratchet envelope description: | SDK-internal endpoint that receives a wire-encoded `0x02` Double-Ratchet envelope from a peer. The body is the raw envelope bytes; the sender's address is supplied via the `X-Shade-Sender-Address` header. The receiver decrypts on the ratchet, parses the control message, and dispatches to the transfer engine. **Note:** `0x02` envelopes are already ratchet-authenticated, so this endpoint does not require a separate signature header. tags: [Transfer] parameters: - name: X-Shade-Sender-Address in: header required: true schema: $ref: '#/components/schemas/Address' requestBody: required: true content: application/octet-stream: schema: type: string format: binary description: Wire-encoded `0x02` ratchet envelope. responses: '200': description: Control envelope accepted content: application/json: schema: type: object required: [ok] properties: ok: { type: boolean } '400': $ref: '#/components/responses/ValidationError' /v1/inbox/register: post: summary: Bind an address to a signing key (TOFU) — inbox relay (V3.6) description: | Claims a recipient address on the inbox relay. The first successful registration binds the address to the supplied signing key; subsequent fetch / ack / unregister calls must be Ed25519-signed by the same key. A different key claiming an existing address is rejected with 401. tags: [Inbox] requestBody: required: true content: application/json: schema: allOf: - $ref: '#/components/schemas/SignedPayload' - type: object required: [address, signingPublicKey] properties: address: { $ref: '#/components/schemas/Address' } signingPublicKey: type: string description: Ed25519 public key, base64 responses: '200': description: Address bound to signing key '401': description: Wrong signing key for an existing registration '409': description: Stale signedAt (replay-window) '400': $ref: '#/components/responses/ValidationError' /v1/inbox/register/{address}: delete: summary: Release an inbox registration description: | Signed unregister — drops the address ↔ signing-key binding and any queued blobs for the address. tags: [Inbox] parameters: - name: address in: path required: true schema: { $ref: '#/components/schemas/Address' } requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SignedPayload' responses: '200': { description: Registration removed } '401': { description: Bad signature for the address } /v1/inbox/{address}: post: summary: Deposit a ciphertext blob for a recipient description: | Signed PUT carrying an opaque ciphertext blob plus `msgId = lower-hex(sha256(ciphertext))`. Idempotent on `(address, msgId)` — replays return `200 { idempotent: true }`. The relay rejects bodies past `maxBlobBytes` (default 1 MiB) and per-recipient quota (default 1000 blobs). tags: [Inbox] parameters: - name: address in: path required: true schema: { $ref: '#/components/schemas/Address' } requestBody: required: true content: application/json: schema: allOf: - $ref: '#/components/schemas/SignedPayload' - type: object required: [msgId, ciphertext, expiresAt, senderPublicKey] properties: msgId: type: string description: Lower-hex SHA-256 of ciphertext bytes. ciphertext: type: string description: Base64-encoded ratchet envelope (`0x02`). expiresAt: type: integer format: int64 description: Unix epoch ms; clamped to `[minTtl, maxTtl]`. senderPublicKey: type: string description: Per-PUT Ed25519 public key, base64. TOFU-bound for the duration of this blob. responses: '200': description: Blob accepted content: application/json: schema: type: object properties: idempotent: { type: boolean } '401': { description: Bad sender signature } '409': { description: Stale signedAt or msgId mismatch } '413': { description: Body past maxBlobBytes } '429': { description: Per-address quota exceeded } /v1/inbox/{address}/fetch: post: summary: Cursor-paginated fetch of queued blobs description: | Signed challenge that returns up to `limit` blobs newer than the supplied cursor. Cursors are server-issued opaque strings. tags: [Inbox] parameters: - name: address in: path required: true schema: { $ref: '#/components/schemas/Address' } requestBody: required: true content: application/json: schema: allOf: - $ref: '#/components/schemas/SignedPayload' - type: object properties: cursor: { type: string, nullable: true } limit: { type: integer, minimum: 1, maximum: 100 } responses: '200': description: Blob page content: application/json: schema: type: object required: [items, nextCursor] properties: items: type: array items: type: object required: [msgId, ciphertext, receivedAt, expiresAt] properties: msgId: { type: string } ciphertext: { type: string } receivedAt: { type: integer, format: int64 } expiresAt: { type: integer, format: int64 } nextCursor: type: string nullable: true '401': { description: Bad signature } /v1/inbox/{address}/{msgId}: delete: summary: Acknowledge (delete) a delivered blob tags: [Inbox] parameters: - name: address in: path required: true schema: { $ref: '#/components/schemas/Address' } - name: msgId in: path required: true schema: { type: string } requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SignedPayload' responses: '200': { description: Blob acknowledged and removed } '401': { description: Bad signature for this address } '404': { description: Blob not found (already acked or expired) } /v1/bridge/stream: get: summary: Server-Sent Events feed of pending envelopes (V3.7) description: | Signed query string (no headers — `EventSource` strips them) drives an SSE feed. Each delivered envelope is emitted as `event: envelope` with a JSON payload. Heartbeats are sent every 15 s as `: ping` SSE comments. Resume on reconnect via `Last-Event-ID`. `kind=stream` is bound into the canonical signed payload to prevent cross-endpoint replay. tags: [Bridge] parameters: - in: query name: address required: true schema: { $ref: '#/components/schemas/Address' } - in: query name: signedAt required: true schema: { type: integer, format: int64 } - in: query name: signature required: true schema: { type: string } - in: query name: kind required: true schema: { type: string, enum: [stream] } responses: '200': description: SSE stream content: text/event-stream: schema: type: string '401': { description: Bad signature } /v1/bridge/poll: get: summary: Long-poll fallback for environments without WS or SSE (V3.7) description: | Returns at most one batch of pending envelopes; holds the connection open up to `timeoutMs` (default 25 s, hard cap 55 s) if nothing is queued. `kind=poll` is bound into the canonical signed payload. tags: [Bridge] parameters: - in: query name: address required: true schema: { $ref: '#/components/schemas/Address' } - in: query name: signedAt required: true schema: { type: integer, format: int64 } - in: query name: signature required: true schema: { type: string } - in: query name: kind required: true schema: { type: string, enum: [poll] } - in: query name: timeoutMs schema: { type: integer, minimum: 0, maximum: 55000 } responses: '200': description: Pending envelope batch (possibly empty after timeout). content: application/json: schema: type: object required: [envelopes] properties: envelopes: type: array items: type: object required: [from, bytes, receivedAt] properties: from: { $ref: '#/components/schemas/Address' } bytes: { type: string, description: 'Base64 envelope' } receivedAt: { type: integer, format: int64 } msgId: { type: string, nullable: true } '401': { description: Bad signature } /v1/bridge/ws: get: summary: WebSocket bridge (Bun WS upgrade) (V3.7) description: | Upgrade to a WebSocket. Each delivered envelope is one JSON frame. Authentication is the same signed-query pattern as the SSE and long-poll endpoints (`kind=ws`). tags: [Bridge] parameters: - in: query name: address required: true schema: { $ref: '#/components/schemas/Address' } - in: query name: signedAt required: true schema: { type: integer, format: int64 } - in: query name: signature required: true schema: { type: string } - in: query name: kind required: true schema: { type: string, enum: [ws] } responses: '101': { description: Switching Protocols (WS upgrade) } '401': { description: Bad signature } /metrics: get: summary: Prometheus metrics description: | Plaintext Prometheus exposition. Counters for HTTP requests, rate-limit rejections, KT publish events, inbox PUT/FETCH/ACK, bridge transports, and inbox prune cycles. tags: [Operations] responses: '200': description: Prometheus exposition. content: text/plain: schema: type: string /healthz: get: summary: Liveness probe tags: [Operations] responses: '200': { description: Process alive } /ready: get: summary: Readiness probe description: | Returns 200 only when the storage backend, the inbox store (if configured) and any KT log store have all reported ready. tags: [Operations] responses: '200': { description: All backends ready } '503': { description: At least one backend not ready } /shade-observer/api/state: get: summary: Current observer snapshot (optional) description: | Returns the aggregated state visible to the dashboard. Only available if the server was started with `SHADE_OBSERVER_TOKEN` set. tags: [Observer] security: - ObserverBearerToken: [] responses: '200': description: Observer state snapshot '401': description: Invalid or missing token components: schemas: Address: type: string description: | An address on the prekey server. Alphanumeric plus `:_-.` characters, max 256 chars. NFKC-normalized. Format is typically `user@domain:deviceId` or `device:uuid`. pattern: '^[a-zA-Z0-9][a-zA-Z0-9:_\-.]{0,255}$' example: "alice@example.com:phone" SignedPayload: type: object description: | Base type for all signed request bodies. The `signature` field is an Ed25519 signature over the canonical JSON of the payload (all fields sorted, `signature` omitted) using the identity's signing private key. `signedAt` must be within ±5 minutes of server time. required: [signedAt, signature] properties: signedAt: type: integer format: int64 description: Unix epoch milliseconds signature: type: string description: Ed25519 signature, base64 SignedPreKeyEntry: type: object required: [keyId, publicKey, signature] properties: keyId: type: integer minimum: 0 publicKey: type: string description: X25519 public key, base64 signature: type: string description: Ed25519 signature over `publicKey`, base64 OneTimePreKeyEntry: type: object required: [keyId, publicKey] properties: keyId: type: integer minimum: 0 publicKey: type: string description: X25519 public key, base64 STH: type: object description: | Signed Tree Head — the operator-signed commitment to the current state of the Key Transparency log (V3.12). Clients verify the signature against a pinned `logPublicKey` and use `rootHash` plus an audit path to validate inclusion proofs. required: [treeSize, timestampMs, rootHash, indexRoot, logId, signature] properties: treeSize: type: integer minimum: 0 description: Number of leaves in the log timestampMs: type: integer format: int64 description: Unix epoch ms; clients reject STHs older than `maxStaleMs` (default 24h) rootHash: type: string description: 32-byte Merkle root hash, base64 indexRoot: type: string description: 32-byte commitment to the address-index, base64 logId: type: string description: 32-byte SHA-256 of the operator's signing public key, base64 signature: type: string description: Ed25519 signature over canonical bytes (DOMAIN_STH || treeSize || timestampMs || rootHash || indexRoot || logId), base64 KTProof: type: object description: | Combined proof attached to bundle responses when KT is active. See `@shade/key-transparency` for full structure of the `body` variant (inclusion / tombstone / absence). required: [sth, body] properties: sth: $ref: '#/components/schemas/STH' body: type: object description: Variant-specific proof body PreKeyBundle: type: object required: [identitySigningKey, identityDHKey, signedPreKey] properties: identitySigningKey: type: string description: Ed25519 public key, base64 identityDHKey: type: string description: X25519 public key, base64 signedPreKey: $ref: '#/components/schemas/SignedPreKeyEntry' oneTimePreKey: $ref: '#/components/schemas/OneTimePreKeyEntry' HealthResponse: type: object properties: status: type: string enum: [ok, error] service: type: string StreamId: type: string description: | URL-safe base64 encoding of the 16-byte streamId allocated by the sender. Always 22 characters, no padding. pattern: '^[A-Za-z0-9_-]{22}$' example: "Tg9k0lZc7r2wQwUYP3KX9A" ChunkAck: type: object required: [ok, lastSeqAcked] properties: ok: type: boolean lastSeqAcked: type: string description: | Highest contiguous `seq` accepted by the receiver for this lane, encoded as a decimal string (`u64` does not fit in a JSON number). pattern: '^[0-9]+$' example: "42" ResumeState: type: object description: | Per-stream resume snapshot: each lane's last accepted seq, plus the stream status. Returned by `GET /v1/transfer/{streamId}/state`. required: [streamId, status, lanes] properties: streamId: $ref: '#/components/schemas/StreamId' status: type: string enum: [active, paused, finished, aborted] lanes: type: array items: type: object required: [laneId, lastSeqAcked] properties: laneId: type: integer minimum: 0 lastSeqAcked: type: string pattern: '^[0-9]+$' ErrorResponse: type: object properties: name: type: string code: type: string pattern: '^SHADE_' message: type: string responses: ValidationError: description: Invalid request body content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' Unauthorized: description: Signature verification failed or replay window exceeded content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' NotFound: description: Address not registered content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' RateLimited: description: Rate limit exceeded headers: Retry-After: schema: type: integer description: Seconds until the client can retry content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' securitySchemes: Ed25519Signature: type: apiKey in: header name: X-Shade-Signature-Info description: | NOT an actual header. Signature is carried inside the JSON request body as the `signature` and `signedAt` fields (see SignedPayload). The canonical form signed is the request body with all keys sorted and the `signature` field omitted. Use your Ed25519 signing key. ObserverBearerToken: type: http scheme: bearer description: | `Authorization: Bearer `. The observer also accepts the token via `?token=...` query string for SSE endpoints that can't set headers. ShadeTransferAuthenticator: type: apiKey in: header name: X-Shade-Signature description: | Per-request Ed25519 signature over a canonical byte string. Three headers must be sent together: - `X-Shade-Sender-Address:
` - `X-Shade-Signed-At: ` (must be within ±5 minutes of receiver clock, same window as the prekey server) - `X-Shade-Signature: ` Canonical message for `/v1/transfer/{streamId}/chunk`: chunk\0 addr=
\0 at=\0 sid=\0 lane=\0 seq=\0 bodyHash=\0 Canonical message for `/v1/transfer/{streamId}/state`: control\0 addr=
\0 at=\0 sid=\0 method=GET\0 path=/v1/transfer//state\0 The receiver verifies using the public Ed25519 signing key it fetched from the prekey server's `/v1/keys/bundle/{address}` endpoint. SDK reference implementation: `packages/shade-sdk/src/streams-bridge.ts` (`ShadeTransferAuthenticator`). OpenAPI 3.1 has no native multi-header security scheme, so this scheme is declared as `apiKey` on `X-Shade-Signature` with the sibling headers documented in the description. Generated clients SHOULD treat all three headers as mandatory.