Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers byte-for-byte. The version bump is semantic: audit-cycle complete, opt-in surface fully exposed, threat model refreshed for every new surface. Highlights: - All 24 @shade/* packages bumped to 4.0.0 in lockstep. - CHANGELOG 4.0.0 section is the canonical manifest of what landed. - THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12 Web-Worker boundary) + residual-risks table refreshed. - OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox, bridge, observer, /metrics, /healthz, /ready. - MIGRATION 0.3.x → 4.0 documented + smoke-tested against shade migrate-storage on a real SQLite DB. - docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer. - scripts/soak.ts harness for the GA-stable 2-week soak window. - All V*.md plans archived under docs/archive/ with Status: Done. - Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen non-realtime stack. Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green. Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports version 4.0.0 on /health. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1088 lines
35 KiB
YAML
1088 lines
35 KiB
YAML
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 <SHADE_OBSERVER_TOKEN>`. 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: <address>`
|
|
- `X-Shade-Signed-At: <unix-ms>` (must be within ±5 minutes of
|
|
receiver clock, same window as the prekey server)
|
|
- `X-Shade-Signature: <base64-of-sig>`
|
|
|
|
Canonical message for `/v1/transfer/{streamId}/chunk`:
|
|
|
|
chunk\0
|
|
addr=<address>\0
|
|
at=<signedAt>\0
|
|
sid=<streamId>\0
|
|
lane=<laneId>\0
|
|
seq=<seq decimal>\0
|
|
bodyHash=<hex(sha256(body))>\0
|
|
|
|
Canonical message for `/v1/transfer/{streamId}/state`:
|
|
|
|
control\0
|
|
addr=<address>\0
|
|
at=<signedAt>\0
|
|
sid=<streamId>\0
|
|
method=GET\0
|
|
path=/v1/transfer/<streamId>/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.
|