release(v4.0.0): Shade GA — V3.x consolidation + audit prep
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
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>
This commit is contained in:
@@ -9,7 +9,7 @@ info:
|
||||
**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: "1.0.0"
|
||||
version: "4.0.0"
|
||||
license:
|
||||
name: MIT
|
||||
url: https://gt.zyon.no/Stian/Shade/raw/branch/main/LICENSE
|
||||
@@ -223,6 +223,583 @@ paths:
|
||||
'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)
|
||||
@@ -292,6 +869,50 @@ components:
|
||||
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]
|
||||
@@ -316,6 +937,54 @@ components:
|
||||
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:
|
||||
@@ -375,3 +1044,44 @@ components:
|
||||
`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.
|
||||
|
||||
Reference in New Issue
Block a user