Node API
REST and WebSocket API for ENC protocol nodes.
Table of Contents
API AppendixOverview
Base URL
https://<node_host>/Content Type
All requests and responses use application/json.
Authentication
| Operation | Method |
|---|---|
| Commit | Signature over commit hash (Schnorr or ECDSA per alg) |
| Query | Session token (see Session) |
| Pull | Session token |
| WebSocket Query | Session token |
| WebSocket Commit | Signature over commit hash (Schnorr or ECDSA per alg) |
Endpoints Summary
Enclave API (all enclaves):| Method | Path | Description |
|---|---|---|
| POST | / | Submit commit, query, or pull request |
| WS | / | Real-time subscriptions |
Request types: Commit, Query, Pull
Proof Retrieval API:| Method | Path | Access | Description |
|---|---|---|---|
| GET | /:enclave/sth | Public | Current signed tree head |
| GET | /:enclave/consistency | Public | CT consistency proof |
| POST | /inclusion | R | CT inclusion proof |
| POST | /bundle | R | Bundle membership proof |
| POST | /state | R | SMT state proof |
| Method | Path | Description |
|---|---|---|
| GET | /nodes/:seq_pub | Resolve node by public key |
| GET | /enclaves/:enclave_id | Resolve enclave to node |
| GET | /resolve/:enclave_id | Combined enclave + node lookup |
| GET | /identity/:id_pub | Resolve identity by public key |
Enclave API
POST / (Commit)
Submit a commit to the enclave.
Detection: Request contains exp field.
{
"hash": "<hex64>",
"enclave": "<hex64>",
"from": "<hex64>",
"type": "<string>",
"content": "<any>",
"exp": 1706000000000,
"tags": [["key", "value"]],
"alg": "schnorr",
"sig": "<hex128>"
}| Field | Type | Required | Description |
|---|---|---|---|
| hash | hex64 | Yes | CBOR hash of commit (see spec.md) |
| enclave | hex64 | Yes | Target enclave ID |
| from | hex64 | Yes | Sender's identity public key |
| type | string | Yes | Event type |
| content | any | Yes | Event content (type-specific) |
| exp | uint | Yes | Expiration timestamp (Unix milliseconds) |
| tags | array | No | Array of [key, value] pairs |
| alg | string | No | Signature algorithm: "schnorr" (default) or "ecdsa". Omit for Schnorr. |
| sig | hex128 | Yes | Signature over hash (algorithm per alg) |
Note: content_hash is NOT transmitted. The node computes content_hash = sha256(utf8_bytes(content)) for commit hash verification (see spec.md).
Response (200 OK): Receipt
{
"type": "Receipt",
"id": "<hex64>",
"hash": "<hex64>",
"timestamp": 1706000000000,
"sequencer": "<hex64>",
"seq": 42,
"alg": "schnorr",
"sig": "<hex128>",
"seq_sig": "<hex128>"
}| Field | Type | Description |
|---|---|---|
| type | string | Always "Receipt" |
| id | hex64 | Event ID |
| hash | hex64 | Original commit hash |
| timestamp | uint | Sequencer timestamp (Unix milliseconds) — recorded when sequencer finalizes the event, not client submission time |
| sequencer | hex64 | Sequencer public key |
| seq | uint | Sequence number |
| alg | string | Signature algorithm from commit ("schnorr" or "ecdsa"). Omitted if "schnorr". |
| sig | hex128 | Client's signature (from commit, algorithm per alg) |
| seq_sig | hex128 | Sequencer's Schnorr signature over event |
Note: Receipt omits enclave for privacy — client already knows which enclave it submitted to.
| Code | HTTP | Description |
|---|---|---|
INVALID_COMMIT | 400 | Malformed commit structure |
INVALID_HASH | 400 | Hash doesn't match CBOR encoding |
INVALID_SIGNATURE | 400 | Signature verification failed |
EXPIRED | 400 | exp < current time |
DUPLICATE | 409 | Commit hash already processed |
UNAUTHORIZED | 403 | Insufficient RBAC permissions |
ENCLAVE_NOT_FOUND | 404 | Enclave doesn't exist |
ENCLAVE_PAUSED | 403 | Enclave is paused |
ENCLAVE_TERMINATED | 410 | Enclave is terminated |
RATE_LIMITED | 429 | Too many requests |
POST / (Query)
Query events from the enclave.
Detection: Request contains type: "Query" field.
{
"type": "Query",
"enclave": "<hex64>",
"from": "<hex64>",
"content": "<encrypted>"
}| Field | Type | Required | Description |
|---|---|---|---|
| type | string | Yes | Must be "Query" |
| enclave | hex64 | Yes | Target enclave ID (plaintext for routing) |
| from | hex64 | Yes | Requester's identity public key |
| content | string | Yes | Encrypted payload (see Encryption) |
{
"session": "<hex136>",
"filter": { ... }
}| Field | Type | Required | Description |
|---|---|---|---|
| session | hex136 | Yes | Session token (see Session) |
| filter | object | Yes | Query filter (see Filter) |
Note: enclave is plaintext for routing — node needs it before decryption.
{
"type": "Response",
"content": "<encrypted>"
}{
"events": [
{ "event": Event, "status": "active" },
{ "event": Event, "status": "updated", "updated_by": "<hex64>" },
...
]
}| Field | Description |
|---|---|
| event | The event object |
| status | "active" — event is current; "updated" — superseded by Update event |
| updated_by | (Present when status: "updated") Event ID of the superseding Update event |
Note: Deleted events are NOT returned. To query deleted event IDs, use the SMT Event Status proof.
Errors:| Code | HTTP | Description |
|---|---|---|
INVALID_QUERY | 400 | Malformed query structure |
INVALID_SESSION | 400 | Session token verification failed |
SESSION_EXPIRED | 401 | Session token expired |
DECRYPT_FAILED | 400 | Cannot decrypt content |
INVALID_FILTER | 400 | Malformed filter |
UNAUTHORIZED | 403 | No read permission |
ENCLAVE_NOT_FOUND | 404 | Enclave doesn't exist |
RATE_LIMITED | 429 | Too many requests |
WebSocket API
Real-time pub/sub for event subscriptions.
Endpoint: wss://<node_host>/
Subscription is automatic: First valid Query on a connection creates a subscription. Node assigns sub_id and begins streaming events.
Connection Model
| Direction | Type | Description |
|---|---|---|
| C → N | Query | First valid Query subscribes, node assigns sub_id |
| C ← N | Event | Stored events (encrypted) |
| C ← N | EOSE | End of stored events |
| C ← N | Event | Live updates (encrypted) |
| C → N | Commit | Write event |
| C ← N | Receipt | Write success |
| C ← N | Error | Write error |
| C → N | Close | Unsubscribe from subscription |
| C ← N | Closed | Subscription terminated |
| C ← N | Notice | Informational message |
Client → Node
| Message | Format | Description |
|---|---|---|
| Query | Same as POST / (Query) | First valid Query subscribes, node assigns sub_id |
| Commit | Same as POST / (Commit) | Write event, returns Receipt |
| Close | { "type": "Close", "sub_id": "<string>" } | Unsubscribe from subscription |
Note: Close unsubscribes from a single subscription. To close the entire WebSocket connection, close the WebSocket transport directly. Closing the transport terminates all active subscriptions.
Node → Client
Event (stored or live):{
"type": "Event",
"sub_id": "<string>",
"event": "<encrypted>"
}{
"type": "EOSE",
"sub_id": "<string>"
}Write Success: Receipt (same as HTTP)
{
"type": "Receipt",
"id": "<hex64>",
"hash": "<hex64>",
"timestamp": 1706000000000,
"sequencer": "<hex64>",
"seq": 42,
"alg": "schnorr",
"sig": "<hex128>",
"seq_sig": "<hex128>"
}{
"type": "Error",
"code": "<CODE>",
"message": "<reason>"
}{
"type": "Closed",
"sub_id": "<string>",
"reason": "<reason_code>"
}| Reason | Description | Client Action |
|---|---|---|
access_revoked | Identity's read permission was revoked | Permanent; re-subscribe if permission restored |
session_expired | Session token expired | Generate new session, re-subscribe |
enclave_terminated | Enclave was terminated | Permanent; no recovery |
enclave_paused | Enclave was paused | Wait for Resume event, then re-subscribe |
enclave_migrated | Enclave migrated to new node | Query Registry for new node, re-subscribe there |
{
"type": "Notice",
"message": "<string>"
}Node Processing
On Query:- Verify session (same as HTTP)
- Check AC (read permission)
- Generate
sub_id, store subscription - Send matching events as
Eventmessages (encrypted) - Send
EOSEmessage - On new events matching filter → push
Eventto client
Same as HTTP, returns Receipt.
On Close:Remove subscription. Terminate connection if none remain.
Connection Lifecycle
Termination conditions:- All subscriptions closed by client
- All sessions expired
- All access revoked
- Client disconnects
- One connection, multiple identities (
from) - Each identity has own session
- Session expiry only affects that identity's subscriptions
HTTP vs WebSocket
| Aspect | HTTP | WebSocket |
|---|---|---|
| Query | One-time response | Subscribe + live updates |
| sub_id | N/A | Node assigns |
| Commit | Receipt | Receipt |
| Session | Per-request | Cached per connection |
| State | Stateless | Subscriptions |
Proof Retrieval API
Endpoints for retrieving cryptographic proofs. Used by clients to verify events and state.
Access Control:| Endpoint | Access |
|---|---|
| STH | Public |
| Consistency | Public |
| Inclusion | Requires R permission |
| State | Requires R permission |
STH (Signed Tree Head)
GET /:enclave/sth
Returns the current signed tree head. Public endpoint for auditing.
Response (200 OK):{
"t": 1706000000000,
"ts": 1000,
"r": "<hex64>",
"sig": "<hex128>"
}See proof.md for STH structure and verification.
Consistency Proof
GET /:enclave/consistency?from=<tree_size>&to=<tree_size>
Returns consistency proof between two tree sizes. Public endpoint for auditing.
| Parameter | Type | Description |
|---|---|---|
| from | uint | Earlier tree size |
| to | uint | Later tree size (omit for current) |
{
"ts1": 500,
"ts2": 1000,
"p": ["<hex64>", ...]
}See proof.md for verification algorithm.
Errors:| Code | HTTP | Description |
|---|---|---|
INVALID_RANGE | 400 | from > to or invalid values |
ENCLAVE_NOT_FOUND | 404 | Enclave doesn't exist |
Inclusion Proof
POST /inclusion
Returns inclusion proof for a bundle. Requires R permission.
Request:{
"type": "Inclusion_Proof",
"enclave": "<hex64>",
"from": "<hex64>",
"content": "<encrypted>"
}{
"session": "<hex136>",
"leaf_index": 42
}{
"type": "Response",
"content": "<encrypted>"
}{
"ts": 1000,
"li": 42,
"p": ["<hex64>", ...],
"events_root": "<hex64>",
"state_hash": "<hex64>"
}| Field | Description |
|---|---|
| ts | Tree size when proof was generated |
| li | Leaf index |
| p | Inclusion proof path |
| events_root | Merkle root of event IDs in bundle |
| state_hash | SMT root after bundle |
See proof.md for verification algorithm.
Errors:| Code | HTTP | Description |
|---|---|---|
INVALID_SESSION | 400 | Session token verification failed |
SESSION_EXPIRED | 401 | Session token expired |
UNAUTHORIZED | 403 | No read permission |
LEAF_NOT_FOUND | 404 | Leaf index out of range |
ENCLAVE_NOT_FOUND | 404 | Enclave doesn't exist |
Bundle Membership Proof
POST /bundle
Returns bundle membership proof for an event. Requires R permission.
Request:{
"type": "Bundle_Proof",
"enclave": "<hex64>",
"from": "<hex64>",
"content": "<encrypted>"
}{
"session": "<hex136>",
"event_id": "<hex64>"
}{
"leaf_index": 42,
"ei": 2,
"s": ["<hex64>", ...],
"events_root": "<hex64>"
}| Field | Description |
|---|---|
| leaf_index | Bundle's position in CT tree |
| ei | Event index within bundle |
| s | Siblings for bundle membership proof |
| events_root | Merkle root of event IDs in bundle |
See proof.md for verification algorithm.
Errors:| Code | HTTP | Description |
|---|---|---|
INVALID_SESSION | 400 | Session token verification failed |
SESSION_EXPIRED | 401 | Session token expired |
UNAUTHORIZED | 403 | No read permission |
EVENT_NOT_FOUND | 404 | Event doesn't exist |
ENCLAVE_NOT_FOUND | 404 | Enclave doesn't exist |
State Proof
POST /state
Returns SMT proof for a key. Requires R permission.
Request:{
"type": "State_Proof",
"enclave": "<hex64>",
"from": "<hex64>",
"content": "<encrypted>"
}{
"session": "<hex136>",
"namespace": "rbac" | "event_status",
"key": "<hex64>",
"tree_size": 1000
}| Field | Required | Description |
|---|---|---|
| session | Yes | Session token |
| namespace | Yes | "rbac" or "event_status" |
| key | Yes | Identity public key (rbac) or event ID (event_status) |
| tree_size | No | Bundle index for historical state (omit for current) |
{
"k": "<hex42>",
"v": "<hex | null>",
"b": "<hex42>",
"s": ["<hex64>", ...],
"state_hash": "<hex64>",
"leaf_index": 999
}| Field | Description |
|---|---|
| k, v, b, s | SMT proof fields (see proof.md) |
| state_hash | SMT root hash for verification |
| leaf_index | Bundle index (0-based) containing this state |
To fully verify a state proof is authentic and from the requested tree position:
- Verify SMT proof against
state_hash(see proof.md) - Request CT inclusion proof for
leaf_indexviaPOST /inclusion - Verify CT inclusion: Recompute leaf as
H(0x00, events_root, state_hash)and verify against signed CT root - Verify STH signature to authenticate the CT root
This binds the SMT state to a specific, signed tree checkpoint. See proof.md for detailed algorithms.
Errors:| Code | HTTP | Description |
|---|---|---|
INVALID_SESSION | 400 | Session token verification failed |
SESSION_EXPIRED | 401 | Session token expired |
INVALID_NAMESPACE | 400 | Unknown namespace |
UNAUTHORIZED | 403 | No read permission |
TREE_SIZE_NOT_FOUND | 404 | Historical state not available |
ENCLAVE_NOT_FOUND | 404 | Enclave doesn't exist |
Webhook Delivery
Node delivers Push messages via HTTPS POST to registered endpoints. See Appendix: Push/Notify for design rationale.
Grant with Push Endpoint
A Grant event with P (push) ops and an endpoint field registers a webhook endpoint. See spec.md for event structure and content fields.
Node maintains queue per (identity, url):
- Aggregates events from all enclaves on this node where identity has P/N permission
- Tracks single
push_seqper queue - All enclaves in a Push delivery share the node's
seq_privfor encryption
An identity MAY register multiple webhook endpoints via separate Grant events with different endpoint values. Each (identity, url) pair has its own push_seq and event queue. Events are delivered independently to each endpoint. To replace an endpoint, submit a new Grant with the new URL; both endpoints remain active until explicitly revoked.
Events within each enclave (push.enclaves[N].events) are ordered by sequence number ascending. Events from different enclaves have no guaranteed relative ordering. If global ordering is required, use per-enclave seq to reconstruct the timeline.
When a Grant changes the webhook URL (new Grant with same role, different URL):
- Old and new endpoints operate as separate queues (no events lost)
- Old endpoint receives events finalized before the Grant
- New endpoint receives events finalized after the Grant
- To stop delivery to old endpoint, explicitly Revoke the role
Delivery Flow
1. Enclave submits Grant event with P ops + endpoint (grants role + registers url)
2. Node adds enclave to (identity, url) queue if not exists
3. On new event, node checks role's P/N permissions
4. Node aggregates events into (identity, url) queue
5. Node periodically POSTs Push to url
6. Node increments push_seqPush
Webhook delivery containing full events (P permission) and/or event IDs (N permission).
HTTP Request:POST <url>
Content-Type: application/json{
"type": "Push",
"from": "<hex64>",
"to": "<hex64>",
"url": "<string>",
"content": "<encrypted>"
}| Field | Type | Description |
|---|---|---|
| type | string | Always "Push" |
| from | hex64 | Sequencer public key |
| to | hex64 | Recipient identity |
| url | string | Webhook URL |
| content | string | Encrypted payload |
{
"push_seq": 130,
"push": {
"enclaves": [
{ "enclave": "<hex64>", "events": [Event, ...] }
]
},
"notify": {
"enclaves": [
{ "enclave": "<hex64>", "seq": 150 }
]
}
}| Field | Type | Description |
|---|---|---|
| push_seq | uint | Sequence number per (identity, url) |
| push | object | Full events for enclaves with P permission |
| push.enclaves[].enclave | hex64 | Enclave ID |
| push.enclaves[].events | array | Array of Event objects |
| notify | object | Latest seq for enclaves with N permission |
| notify.enclaves[].enclave | hex64 | Enclave ID |
| notify.enclaves[].seq | uint | Latest sequence number in this enclave |
Either push or notify may be omitted if empty.
Encryption: See Encryption.
Delivery semantics:- At-least-once delivery
- Exponential backoff on failure
- Recipient MUST dedupe by
event.id(for push) or track last synced seq (for notify)
Expected response: 200 OK
Pull Fallback
If webhook delivery fails, recipient can pull missed batches.
Request:{
"type": "Pull",
"enclave": "<hex64>",
"from": "<hex64>",
"content": "<encrypted>"
}Note: enclave is required in the outer request for signer key derivation during decryption. Any enclave on the node where the identity has a registered webhook can be used.
{
"session": "<hex136>",
"url": "<string>",
"push_seq": { "start_after": 5, "end_at": 7 },
"enclave": "<hex64>"
}| Field | Type | Required | Description |
|---|---|---|---|
| session | hex136 | Yes | Session token |
| url | string | Yes | Registered webhook endpoint |
| push_seq | uint, [uint, ...], or Range | Yes | Batch sequence(s) to retrieve |
| enclave | hex64 | No | Filter results to single enclave |
- Single:
6— returns batch 6 - Array:
[6, 7, 8]— returns batches 6, 7, 8 - Range:
{ "start_after": 5, "end_at": 7 }— returns batches 6, 7
{
"type": "Response",
"content": "<encrypted>"
}[
{
"push_seq": 6,
"push": { "enclaves": [...] },
"notify": { "enclaves": [...] }
},
{
"push_seq": 7,
"push": { "enclaves": [...] },
"notify": { "enclaves": [...] }
}
]Array of batches. Each batch has same structure as Push delivery content.
Range Handling:When push_seq is a Range:
start_after/end_atare inclusive/exclusive as documented- Results are ordered by
push_seqascending - If
enclavefilter is provided, only events from that enclave are included in each batch - If
enclaveis omitted, all enclaves in the original batch are included
Node retries webhook delivery with exponential backoff per spec.md. After max retries exhausted, batch is moved to dead-letter queue. Recipient can recover via Pull fallback.
Encryption: Same as Query. See Encryption.
Registry DataView API
The Registry is an enclave with a DataView server that provides discovery endpoints. These endpoints are served by the Registry's DataView, not by the generic Enclave API.
Base URL: Registry node endpoint (discovered via bootstrap)
GET /nodes/
Resolve node by sequencer public key.
Path Parameters:| Param | Type | Description |
|---|---|---|
| seq_pub | hex64 | Sequencer public key |
GET /nodes/a1b2c3...{
"seq_pub": "<hex64>",
"endpoints": [
{ "uri": "https://node.example.com", "priority": 1 },
{ "uri": "https://backup.example.com", "priority": 2 }
],
"protocols": ["https", "wss"],
"enc_v": 1
}| Field | Type | Description |
|---|---|---|
| seq_pub | hex64 | Sequencer public key |
| endpoints | array | Endpoints sorted by priority (1 = highest) |
| endpoints[].uri | string | Endpoint URI |
| endpoints[].priority | uint | Priority (lower = preferred) |
| protocols | array | Supported protocols |
| enc_v | uint | ENC protocol version |
| Code | HTTP | Description |
|---|---|---|
NODE_NOT_FOUND | 404 | Node not registered |
GET /enclaves/
Resolve enclave to hosting node.
Path Parameters:| Param | Type | Description |
|---|---|---|
| enclave_id | hex64 | Enclave identifier |
GET /enclaves/d4e5f6...{
"enclave_id": "<hex64>",
"sequencer": "<hex64>",
"creator": "<hex64>",
"created_at": 1706000000000,
"app": "chat",
"desc": "Team chat",
"meta": {}
}| Field | Type | Required | Description |
|---|---|---|---|
| enclave_id | hex64 | Yes | Enclave identifier |
| sequencer | hex64 | Yes | Current sequencer public key |
| creator | hex64 | No | Creator's identity key |
| created_at | uint | No | Creation timestamp (Unix milliseconds) |
| app | string | No | Application identifier |
| desc | string | No | Human-readable description |
| meta | object | No | Application-defined metadata |
| Code | HTTP | Description |
|---|---|---|
ENCLAVE_NOT_FOUND | 404 | Enclave not registered |
GET /resolve/
Combined lookup: enclave → node (convenience endpoint).
Path Parameters:| Param | Type | Description |
|---|---|---|
| enclave_id | hex64 | Enclave identifier |
GET /resolve/d4e5f6...{
"enclave": {
"enclave_id": "<hex64>",
"sequencer": "<hex64>",
"creator": "<hex64>",
"created_at": 1706000000000,
"app": "chat",
"desc": "Team chat",
"meta": {}
},
"node": {
"seq_pub": "<hex64>",
"endpoints": [
{ "uri": "https://node.example.com", "priority": 1 }
],
"protocols": ["https", "wss"],
"enc_v": 1
}
}| Code | HTTP | Description |
|---|---|---|
ENCLAVE_NOT_FOUND | 404 | Enclave not registered |
NODE_NOT_FOUND | 404 | Sequencer node not registered |
GET /identity/
Resolve identity by public key. Returns the identity's registered enclaves.
Path Parameters:| Param | Type | Description |
|---|---|---|
| id_pub | hex64 | Identity public key |
GET /identity/a1b2c3...{
"id_pub": "<hex64>",
"enclaves": {
"personal": "<enclave_id>",
"dm": "<enclave_id>"
}
}| Field | Type | Description |
|---|---|---|
| id_pub | hex64 | Identity public key |
| enclaves | object | Map of label → enclave_id |
| Code | HTTP | Description |
|---|---|---|
IDENTITY_NOT_FOUND | 404 | Identity not registered |
Appendix
Session
Session tokens provide stateless authentication for queries.
Token Format
136 hex characters = 68 bytes
Bytes 0-31: r (Schnorr signature R value)
Bytes 32-63: session_pub (x-only public key)
Bytes 64-67: expires (big-endian uint32, Unix seconds)Client Derivation
1. expires = now + duration (max 7200 seconds)
2. message = "enc:session:" || be32(expires)
3. sig = schnorr_sign(sha256(message), id_priv) // always Schnorr
4. r = sig[0:32]
5. s = sig[32:64]
6. session_priv = s
7. session_pub = point(s)
8. session = hex(r || session_pub || be32(expires))Note: Session token derivation always uses Schnorr. The alg field does not apply to session tokens. Hardware signers that only support ECDSA (e.g., NFC/JavaCard) can submit commits but cannot create session tokens. Such devices require a software co-signer or proxy for query/subscription authentication.
Node Verification
O(1) EC math — no signature verification.
1. Parse: r, session_pub, expires from token
2. Check: expires > now - 60 (allow 60s clock skew)
3. Check: expires ≤ now + 7200 + 60 (allow 60s clock skew)
4. message = "enc:session:" || be32(expires)
5. expected = r + sha256(r || from || message) * from
6. Verify: session_pub == expectedClock skew tolerance (±60 seconds) allows clients with slightly-off clocks to connect.
Curve Parameters:All EC arithmetic uses secp256k1. The curve order is:
n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141All scalar operations (addition, multiplication) are performed modulo n.
Signer Derivation
Per-node signer for ECDH.
t = sha256(session_pub || seq_pub || enclave)
signer_priv = session_priv + t (mod n)
signer_pub = session_pub + t * GThe enclave ID is included in the signer derivation so that the same session_pub produces different signer keys for different enclaves. This provides per-enclave key isolation:
- Session token is reusable across enclaves (same
session_pub) - Derived encryption key (via
signer_pub) is enclave-specific - A compromised signer key in Enclave A cannot decrypt messages for Enclave B
This is intentional and provides defense-in-depth.
Security Properties:| Property | Protected? | Reason |
|---|---|---|
| Cross-session reuse | ✓ | Different session_pub → different t → different signer |
| Cross-enclave reuse | ✓ | Different enclave → different t → different signer |
| Cross-node reuse | ✓ | Different seq_pub → different t → different signer |
| Same session + enclave | Same signer | Intentional — enables session continuity |
No additional replay protection is needed; the derivation inputs guarantee uniqueness.
Session Properties
| Property | Value |
|---|---|
| Max expiry | 7200 seconds (2 hours) |
| Timestamp unit | Seconds (for uint32 compactness; API timestamps use milliseconds) |
| Reusable | Yes, until expiry |
| Per-node signer | Yes (different ECDH per node) |
| Multi-key | One connection, multiple sessions |
Filter
Query filter for event retrieval.
Structure
{
"id": "<hex64> | [<hex64>, ...]",
"seq": "<uint> | [<uint>, ...] | Range",
"type": "<string> | [<string>, ...]",
"from": "<hex64> | [<hex64>, ...]",
"tags": { "<tag_name>": "<value> | [<value>, ...] | true" },
"timestamp": "Range (Unix ms)",
"limit": 100,
"reverse": false
}All fields are optional. Omitted field = no filter (match all).
Tags Filter:The tags field filters events by tag presence or value:
{ "r": "abc123..." }— events withrtag matching value{ "r": ["abc123...", "def456..."] }— events withrtag matching any value{ "auto-delete": true }— events withauto-deletetag (any value)
Events are sorted by sequence number ascending unless reverse: true. This is the canonical enclave order.
Range
{
"start_at": 100, // >= 100
"start_after": 100, // > 100
"end_at": 200, // <= 200
"end_before": 200 // < 200
}Semantics
| Pattern | Meaning |
|---|---|
| Top-level fields | AND |
| Array values | OR |
| Omitted field | Match all |
Limits
| Field | Max |
|---|---|
id[] | 100 |
seq[] | 100 |
type[] | 20 |
from[] | 100 |
tags keys | 10 |
tags values per key | 20 |
limit | 1000 |
Examples
By type:{ "type": "Chat_Message" }{ "type": "Chat_Message", "from": ["abc...", "def..."] }{ "timestamp": { "start_at": 1704067200000, "end_before": 1704153600000 } }{ "seq": { "start_after": 150 }, "limit": 100 }{ "type": "Chat_Message", "reverse": true, "limit": 20 }Encryption
Client-node communication is encrypted using ECDH + XChaCha20Poly1305.
Overview
| Context | Client Key | Node Key | HKDF Label |
|---|---|---|---|
| Query request | signer_priv | seq_pub | "enc:query" |
| Query response | signer_priv | seq_pub | "enc:response" |
| Pull request | signer_priv | seq_pub | "enc:query" |
| Pull response | signer_priv | seq_pub | "enc:response" |
| WebSocket event | signer_priv | seq_pub | "enc:response" |
| Push delivery | to (recipient) | seq_priv | "enc:push" |
Query Encryption (Client → Node)
Client encrypts query content:
1. Derive signer from session:
t = sha256(session_pub || seq_pub || enclave)
signer_priv = session_priv + t (mod n)
2. Compute shared secret:
shared = ECDH(signer_priv, seq_pub)
3. Derive key and encrypt:
key = HKDF(shared, "enc:query")
ciphertext = XChaCha20Poly1305_Encrypt(key, plaintext)Node decrypts:
1. Derive signer_pub from session_pub:
t = sha256(session_pub || seq_pub || enclave)
signer_pub = session_pub + t * G
2. Compute shared secret:
shared = ECDH(seq_priv, signer_pub)
3. Decrypt:
key = HKDF(shared, "enc:query")
plaintext = XChaCha20Poly1305_Decrypt(key, ciphertext)Response Encryption (Node → Client)
Node encrypts response (HTTP and WebSocket):
shared = ECDH(seq_priv, signer_pub)
key = HKDF(shared, "enc:response")
ciphertext = XChaCha20Poly1305_Encrypt(key, plaintext)Client decrypts:
shared = ECDH(signer_priv, seq_pub)
key = HKDF(shared, "enc:response")
plaintext = XChaCha20Poly1305_Decrypt(key, ciphertext)Push Encryption (Node → Webhook)
Node encrypts webhook payload:
shared = ECDH(seq_priv, to)
key = HKDF(shared, "enc:push")
ciphertext = XChaCha20Poly1305_Encrypt(key, plaintext)Recipient decrypts:
shared = ECDH(recipient_priv, seq_pub)
key = HKDF(shared, "enc:push")
plaintext = XChaCha20Poly1305_Decrypt(key, ciphertext)Primitives
| Primitive | Specification |
|---|---|
| ECDH | secp256k1 |
| HKDF | HKDF-SHA256 |
| AEAD | XChaCha20Poly1305 |
IKM = ECDH shared secret (32 bytes)
salt = empty (no salt)
info = UTF-8 encoded label string, NO null terminator
e.g., "enc:query" = 9 bytes: 0x65 0x6E 0x63 0x3A 0x71 0x75 0x65 0x72 0x79
L = 32 bytes (256-bit key)nonce = random 24 bytes, prepended to ciphertext
ciphertext_wire = nonce || ciphertext || tagRecipient extracts first 24 bytes as nonce before decryption.
Minimum Length: ciphertext_wire MUST be at least 40 bytes (24-byte nonce + 16-byte Poly1305 tag). Shorter values indicate malformed or truncated ciphertext — implementations MUST reject with DECRYPT_FAILED.
Error Codes
HTTP Status Mapping
| HTTP | Category |
|---|---|
| 400 | Client error (malformed request) |
| 401 | Authentication error |
| 403 | Authorization error |
| 404 | Not found |
| 409 | Conflict |
| 410 | Gone |
| 429 | Rate limited |
| 500 | Internal server error |
| 502 | Upstream unreachable |
| 503 | Service temporarily unavailable |
Error Response Format
{
"type": "Error",
"code": "<CODE>",
"message": "<human readable>"
}Error Codes
| Code | HTTP | Description |
|---|---|---|
INVALID_COMMIT | 400 | Malformed commit structure |
INVALID_HASH | 400 | Hash doesn't match CBOR encoding |
INVALID_SIGNATURE | 400 | Signature verification failed |
INVALID_QUERY | 400 | Malformed query structure |
INVALID_SESSION | 400 | Session token verification failed |
INVALID_FILTER | 400 | Malformed filter |
DECRYPT_FAILED | 400 | Cannot decrypt content |
SESSION_EXPIRED | 401 | Session token expired |
EXPIRED | 400 | Commit exp < current time |
UNAUTHORIZED | 403 | Insufficient RBAC permissions |
ENCLAVE_PAUSED | 403 | Enclave is paused |
DUPLICATE | 409 | Commit hash already processed |
NODE_NOT_FOUND | 404 | Node not registered |
ENCLAVE_NOT_FOUND | 404 | Enclave not registered |
IDENTITY_NOT_FOUND | 404 | Identity not registered |
ENCLAVE_TERMINATED | 410 | Enclave is terminated |
ENCLAVE_MIGRATED | 410 | Enclave has migrated to another node |
RATE_LIMITED | 429 | Too many requests |
INTERNAL_ERROR | 500 | Internal server error |
WebSocket errors use the same JSON format as HTTP errors. HTTP status codes do not apply to WebSocket; use the application-level code field instead.
Push/Notify
Problem
A DataView server may have P/N permissions across hundreds of enclaves on the same node. Naive approach — one HTTP request per event per enclave — creates massive overhead.
Solution
Node aggregates events into a single queue per (identity, url) pair.
Enclave A ──┐
Enclave B ──┼──► Queue (identity, url) ──► Single POST to url
Enclave C ──┘Why This Is Efficient
| Aspect | Benefit |
|---|---|
| Batching | One POST delivers events from many enclaves |
| Single sequence | One push_seq for gap detection across all enclaves |
| Periodic aggregation | Node batches events instead of instant push per event |
| Unified message | Push and Notify combined in single delivery |
Pull Fallback Efficiency
When webhook delivery fails, recipient uses Pull to recover. The single sequence number makes this extremely efficient:
Without unified sequence (naive):Recipient must track:
- Enclave A: last_seq = 42
- Enclave B: last_seq = 17
- Enclave C: last_seq = 103
...hundreds of enclaves...
Recovery requires:
- One Query per enclave
- Complex state management
- N round trips for N enclavesRecipient tracks:
- push_seq = 130
Recovery requires:
- One Pull request with Range
- Returns all missed batches
- Single round trip- Received push_seq 5, then 8 → know you missed 6, 7
- Pull with
push_seq: { "start_after": 5, "end_at": 7 }returns both batches in one request
This is why the queue is per (identity, url) not per enclave — it enables O(1) state tracking regardless of how many enclaves you're subscribed to.
Push vs Notify (within same message)
A single Push delivery contains both:
push.enclaves[]— full events for enclaves with P permissionnotify.enclaves[]— latest seq for enclaves with N permission
| push | notify | |
|---|---|---|
| Content | Full events | Latest seq only |
| Use case | Real-time sync | Lightweight alerts |
Permissions
| Permission | What it grants |
|---|---|
| P (Push) | Receive full event content in push.enclaves[] |
| N (Notify) | Receive latest seq in notify.enclaves[] |
| R (Read) | Query full event content from enclave |
Important: P delivers full content directly. N only delivers the latest seq — to fetch full events, you need R permission on that enclave.
Example:Identity has:
- Enclave A: P permission → full events in push.enclaves[]
- Enclave B: N permission → seq in notify.enclaves[]
- Enclave C: N + R permissions → seq in notify.enclaves[], can Query full eventsIf you only have N (no R), you know new events exist but cannot read their content. This is useful for:
- Mobile push notifications (just alert user)
- Protocol-level sync signals (trigger sync via other means)
- Audit logging (record that activity occurred)
Typical Pattern
- Receive Push with notify.enclaves[].seq
- Query events with
{ "seq": { "start_after": last_synced_seq } }(requires R permission) - Full events arrive directly in push.enclaves[] if you have P permission
Node Internal Table
Key: (identity, url)
Value: { push_seq, enclaves[] }For each enclave, node tracks which events to include based on role permissions.