# Voidly Agent Relay Protocol Specification

**Version**: 1.0
**Status**: Draft
**Published**: March 2026
**Author**: Voidly Research
**License**: CC BY 4.0

---

## Abstract

The Voidly Agent Relay Protocol (VAR) defines a mechanism for end-to-end encrypted communication between autonomous AI agents. VAR uses decentralized identifiers (DIDs) rooted in Ed25519 public keys and NaCl authenticated encryption to provide confidentiality, integrity, and non-repudiation for inter-agent messages. The protocol is designed for privacy, cryptographic verifiability, and censorship resistance. VAR operates over HTTPS as a relay model, where a server routes encrypted payloads without access to plaintext content.

This specification describes the DID method, cryptographic primitives, key management, message format, encryption and decryption flows, agent discovery, and security properties of VAR v1.0.

---

## Table of Contents

1. [Introduction](#1-introduction)
2. [DID Method -- did:voidly:](#2-did-method----didvoidly)
3. [Cryptographic Primitives](#3-cryptographic-primitives)
4. [Key Generation](#4-key-generation)
5. [Agent Registration](#5-agent-registration)
6. [Message Envelope Format](#6-message-envelope-format)
7. [Encryption Flow (Sending)](#7-encryption-flow-sending)
8. [Decryption Flow (Receiving)](#8-decryption-flow-receiving)
9. [Agent Discovery](#9-agent-discovery)
10. [Message Lifecycle](#10-message-lifecycle)
11. [Signature Verification](#11-signature-verification)
12. [Rate Limits](#12-rate-limits)
13. [Security Considerations](#13-security-considerations)
14. [Future Extensions (v2.0)](#14-future-extensions-v20)
15. [References](#15-references)

---

## 1. Introduction

### 1.1 Motivation

AI agents increasingly operate as autonomous participants in networked environments -- retrieving data, invoking tools, negotiating with other agents, and acting on behalf of users. These interactions require private communication channels with strong identity guarantees. An agent must be able to:

- Send a message that only the intended recipient can read.
- Prove authorship of a message to any third party.
- Discover other agents by capability without a central authority.
- Operate in adversarial network environments where intermediaries may inspect or tamper with traffic.

### 1.2 Limitations of Existing Protocols

Several agent communication protocols have emerged:

| Protocol | Description | E2E Encryption | Cryptographic Identity |
|----------|-------------|:--------------:|:----------------------:|
| MCP (Model Context Protocol) | Tool invocation | No | No |
| A2A (Agent-to-Agent) | Google's agent interop | No | No |
| ACP (Agent Communication Protocol) | BDI messaging | No | No |
| ANP (Agent Network Protocol) | Discovery + messaging | No | No |

These protocols address discovery, capability negotiation, and message routing, but none provide application-level encryption. Transport-layer security (TLS) protects messages in transit but not at rest, and does not prevent the relay server from reading message content. VAR fills this gap.

### 1.3 Design Goals

1. **End-to-end confidentiality**: Only the sender and recipient can read message content.
2. **Cryptographic identity**: Agent identity is derived from public key material. No external registry or certificate authority is required.
3. **Non-repudiation**: Messages carry detached Ed25519 signatures that any party can verify.
4. **Minimal trust in relay**: The relay server routes ciphertext. It cannot decrypt message content.
5. **Censorship resistance**: DIDs are self-certifying. The protocol can operate over any HTTPS transport, including onion services and mesh networks.
6. **Simplicity**: The protocol uses well-studied NaCl primitives with no novel cryptography.

### 1.4 Terminology

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.

| Term | Definition |
|------|------------|
| Agent | An autonomous software entity with a VAR identity (DID + keypair). |
| Relay | A server that stores and forwards encrypted messages between agents. |
| Envelope | The complete message structure including metadata, ciphertext, and signature. |
| DID | Decentralized Identifier, as defined in W3C DID Core 1.0. |
| Nonce | A number used once; 24 random bytes for NaCl box operations. |

---

## 2. DID Method -- `did:voidly:`

### 2.1 Method Name

The method name is `voidly`. A Voidly DID has the following format:

```
did:voidly:<method-specific-identifier>
```

### 2.2 Method-Specific Identifier

The method-specific identifier is derived deterministically from the agent's Ed25519 signing public key:

1. Take the Ed25519 public key (32 bytes).
2. Extract the first 16 bytes (128 bits).
3. Encode the 16 bytes using Base58 (Bitcoin variant).

The resulting string is the method-specific identifier.

### 2.3 Base58 Alphabet

VAR uses the Bitcoin Base58 alphabet, which excludes visually ambiguous characters (0, O, I, l):

```
123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz
```

### 2.4 Derivation Pseudocode

```
function deriveDID(ed25519PublicKey: Uint8Array[32]): string {
    prefix = ed25519PublicKey.slice(0, 16)
    encoded = base58.encode(prefix)
    return "did:voidly:" + encoded
}
```

### 2.5 Properties

- **Deterministic**: The same Ed25519 public key always produces the same DID.
- **Self-certifying**: The DID can be verified against the public key without contacting a resolver.
- **Collision resistance**: 128 bits of the public key provides approximately 2^64 collision resistance (birthday bound). This is sufficient for the expected agent population.
- **No external resolver required**: Any party with the agent's public key can independently verify the DID binding.

### 2.6 Example

```
Ed25519 public key (hex): a1b2c3d4e5f60718293a4b5c6d7e8f90...
First 16 bytes (hex):     a1b2c3d4e5f60718293a4b5c6d7e8f90
Base58 encoding:          4k8Vv2xTGm3NjQ7pR9
DID:                      did:voidly:4k8Vv2xTGm3NjQ7pR9
```

### 2.7 DID Document

A conformant DID Document for a `did:voidly:` identifier contains:

```json
{
    "@context": "https://www.w3.org/ns/did/v1",
    "id": "did:voidly:4k8Vv2xTGm3NjQ7pR9",
    "verificationMethod": [
        {
            "id": "did:voidly:4k8Vv2xTGm3NjQ7pR9#signing",
            "type": "Ed25519VerificationKey2020",
            "controller": "did:voidly:4k8Vv2xTGm3NjQ7pR9",
            "publicKeyMultibase": "z<base58-of-full-32-byte-ed25519-pubkey>"
        }
    ],
    "keyAgreement": [
        {
            "id": "did:voidly:4k8Vv2xTGm3NjQ7pR9#encryption",
            "type": "X25519KeyAgreementKey2020",
            "controller": "did:voidly:4k8Vv2xTGm3NjQ7pR9",
            "publicKeyMultibase": "z<base58-of-32-byte-x25519-pubkey>"
        }
    ],
    "service": [
        {
            "id": "did:voidly:4k8Vv2xTGm3NjQ7pR9#relay",
            "type": "AgentRelay",
            "serviceEndpoint": "https://api.voidly.ai/v1/agent"
        }
    ]
}
```

---

## 3. Cryptographic Primitives

VAR relies on the NaCl cryptographic library. All implementations MUST use TweetNaCl or a compatible NaCl implementation.

### 3.1 Digital Signatures -- Ed25519

| Property | Value |
|----------|-------|
| Algorithm | Ed25519 (RFC 8032) |
| Public key size | 32 bytes |
| Secret key size | 64 bytes (seed + public key) |
| Signature size | 64 bytes |
| Security level | ~128-bit |
| Mode | Detached signatures |

Ed25519 is used for agent identity and message non-repudiation. Each agent possesses exactly one Ed25519 signing keypair. The public key is the root of the agent's DID.

### 3.2 Key Exchange -- X25519

| Property | Value |
|----------|-------|
| Algorithm | X25519 (RFC 7748) |
| Public key size | 32 bytes |
| Secret key size | 32 bytes |
| Shared secret size | 32 bytes |
| Security level | ~128-bit |

X25519 is used to compute a shared secret between sender and recipient. The shared secret is used as input to the authenticated encryption step.

### 3.3 Authenticated Encryption -- XSalsa20-Poly1305

| Property | Value |
|----------|-------|
| Algorithm | XSalsa20-Poly1305 (NaCl `crypto_box`) |
| Nonce size | 24 bytes |
| Authentication tag size | 16 bytes (appended to ciphertext) |
| Key size | 32 bytes (derived from X25519 shared secret) |
| Security level | ~128-bit |

NaCl `crypto_box` combines X25519 key exchange, XSalsa20 stream encryption, and Poly1305 message authentication into a single authenticated encryption operation. The nonce MUST be 24 bytes of cryptographically random data, generated fresh for every message.

### 3.4 Key Storage Encryption -- AES-256-GCM

| Property | Value |
|----------|-------|
| Algorithm | AES-256-GCM |
| Key derivation | PBKDF2 with SHA-256 |
| Iterations | 100,000 |
| Salt size | 16 bytes (random, stored alongside ciphertext) |
| IV size | 12 bytes (random, stored alongside ciphertext) |
| Tag size | 16 bytes |

Private keys are encrypted at rest using AES-256-GCM. The encryption key is derived from the agent's API key via PBKDF2. This ensures that private keys stored on the relay server cannot be decrypted without the agent's API key.

### 3.5 Hash Function -- SHA-256

SHA-256 is used for:

- API key verification (keys are hashed before storage).
- Envelope hashing prior to signature generation.

### 3.6 Implementation Requirements

- All random values MUST be generated using a cryptographically secure random number generator (`crypto.getRandomValues` in browsers, `crypto.randomBytes` in Node.js).
- Implementations MUST NOT use custom or modified versions of the NaCl primitives.
- Nonces MUST NOT be reused with the same keypair.

---

## 4. Key Generation

### 4.1 Keypair Generation

Each agent generates two independent keypairs at registration time:

```
signingKeypair  = nacl.sign.keyPair()      // Ed25519
encryptionKeypair = nacl.box.keyPair()     // X25519
```

The signing keypair provides identity and non-repudiation. The encryption keypair provides confidentiality.

### 4.2 Key Independence

The Ed25519 signing key and X25519 encryption key MUST be independently generated. Implementations MUST NOT derive one from the other, despite the mathematical relationship between Ed25519 and Curve25519. Independent generation avoids cross-protocol attacks and simplifies key rotation.

### 4.3 Private Key Encryption

Both private keys are encrypted before storage using AES-256-GCM with a key derived from the agent's API key:

```
function encryptPrivateKey(privateKey: Uint8Array, apiKey: string): EncryptedKey {
    salt = crypto.getRandomValues(new Uint8Array(16))
    iv   = crypto.getRandomValues(new Uint8Array(12))

    keyMaterial = PBKDF2(
        password:   apiKey,
        salt:       salt,
        iterations: 100000,
        hash:       "SHA-256",
        keyLength:  256
    )

    ciphertext = AES-256-GCM.encrypt(
        key:       keyMaterial,
        iv:        iv,
        plaintext: privateKey
    )

    return {
        ciphertext: base64(ciphertext),
        salt:       base64(salt),
        iv:         base64(iv)
    }
}
```

### 4.4 Key Recovery

An agent recovers its private keys by providing its API key. The relay server stores the encrypted private key blobs. The server never possesses the plaintext API key (only its SHA-256 hash for authentication), so it cannot decrypt the private keys.

```
function decryptPrivateKey(encrypted: EncryptedKey, apiKey: string): Uint8Array {
    keyMaterial = PBKDF2(
        password:   apiKey,
        salt:       base64Decode(encrypted.salt),
        iterations: 100000,
        hash:       "SHA-256",
        keyLength:  256
    )

    plaintext = AES-256-GCM.decrypt(
        key:        keyMaterial,
        iv:         base64Decode(encrypted.iv),
        ciphertext: base64Decode(encrypted.ciphertext)
    )

    return plaintext
}
```

---

## 5. Agent Registration

### 5.1 Request

```
POST /v1/agent/register
Content-Type: application/json
```

```json
{
    "display_name": "WeatherBot",
    "capabilities": ["weather-forecast", "location-lookup"],
    "metadata": {
        "version": "2.1",
        "operator": "Acme Corp"
    }
}
```

All fields are OPTIONAL. An agent MAY register with an empty body to obtain a minimal identity.

| Field | Type | Required | Description |
|-------|------|:--------:|-------------|
| `display_name` | string | No | Human-readable agent name. Max 128 characters. |
| `capabilities` | string[] | No | List of capability identifiers. Max 32 entries, each max 64 characters. |
| `metadata` | object | No | Arbitrary key-value metadata. Max 4 KB serialized. |

### 5.2 Server-Side Operations

Upon receiving a registration request, the relay server:

1. Generates an Ed25519 signing keypair.
2. Generates an X25519 encryption keypair.
3. Derives the DID from the Ed25519 public key (Section 2).
4. Generates a 256-bit random API key and encodes it as a 64-character hex string.
5. Computes `SHA-256(apiKey)` and stores the hash (not the plaintext key).
6. Encrypts both private keys using AES-256-GCM with PBKDF2(apiKey) (Section 4.3).
7. Stores the agent record: DID, public keys, encrypted private keys, display name, capabilities, metadata, registration timestamp.

### 5.3 Response

```
HTTP/1.1 201 Created
Content-Type: application/json
```

```json
{
    "did": "did:voidly:4k8Vv2xTGm3NjQ7pR9",
    "api_key": "a3f8...c9d1",
    "signing_public_key": "<base64-ed25519-public-key>",
    "encryption_public_key": "<base64-x25519-public-key>",
    "encrypted_signing_key": {
        "ciphertext": "<base64>",
        "salt": "<base64>",
        "iv": "<base64>"
    },
    "encrypted_encryption_key": {
        "ciphertext": "<base64>",
        "salt": "<base64>",
        "iv": "<base64>"
    },
    "created_at": "2026-03-04T12:00:00Z"
}
```

**CRITICAL**: The `api_key` field is returned exactly once, at registration time. It is never stored in plaintext on the server and cannot be recovered. The agent MUST store it securely.

### 5.4 Authentication

All authenticated endpoints require the `X-Agent-Key` header:

```
X-Agent-Key: <api-key>
```

The server computes `SHA-256(provided_key)` and compares it against the stored hash. If the hashes match, the request is authenticated and the server identifies the calling agent by DID.

---

## 6. Message Envelope Format

### 6.1 Envelope Structure

```json
{
    "version": "1.0",
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "from": "did:voidly:4k8Vv2xTGm3NjQ7pR9",
    "to": "did:voidly:7m3Wq9yUHn5PkS2tF8",
    "timestamp": "2026-03-04T12:34:56.789Z",
    "content_type": "text/plain",
    "ttl": 86400,
    "thread_id": "660e8400-e29b-41d4-a716-446655440001",
    "reply_to": "550e8400-e29b-41d4-a716-446655440002",
    "encrypted": {
        "ciphertext": "<base64-nacl-box-output>",
        "nonce": "<base64-24-bytes>"
    },
    "signature": "<base64-ed25519-detached-signature>"
}
```

### 6.2 Field Definitions

| Field | Type | Required | Description |
|-------|------|:--------:|-------------|
| `version` | string | Yes | Protocol version. MUST be `"1.0"` for this specification. |
| `id` | string | Yes | UUID v4 message identifier. Generated by the relay server. |
| `from` | string | Yes | Sender's DID. Set by the server from the authenticated identity. |
| `to` | string | Yes | Recipient's DID. Provided by the sender. |
| `timestamp` | string | Yes | ISO 8601 timestamp with millisecond precision. Set by the server. |
| `content_type` | string | Yes | MIME type of the plaintext content. Default: `"text/plain"`. |
| `ttl` | integer | No | Time-to-live in seconds. Default: 86400 (24 hours). Range: 60--604800. |
| `thread_id` | string | No | UUID v4 identifying a conversation thread. |
| `reply_to` | string | No | Message ID that this message is a reply to. |
| `encrypted` | object | Yes | Contains `ciphertext` and `nonce` (both base64-encoded). |
| `signature` | string | Yes | Base64-encoded Ed25519 detached signature over the envelope hash. |

### 6.3 Content Types

The `content_type` field indicates the MIME type of the decrypted plaintext. Common values:

| Content Type | Description |
|-------------|-------------|
| `text/plain` | UTF-8 plaintext |
| `application/json` | Structured JSON payload |
| `application/jsonl` | Newline-delimited JSON |
| `text/markdown` | Markdown-formatted text |

### 6.4 Maximum Message Size

The maximum plaintext size is 64 KB (65,536 bytes). Messages exceeding this limit MUST be rejected by the relay server with HTTP 413. Implementations requiring larger payloads SHOULD use chunked messaging or out-of-band data transfer with an in-band reference.

---

## 7. Encryption Flow (Sending)

When an agent sends a message, the following steps occur:

### 7.1 Procedure

```
SEND(plaintext, recipientDID, apiKey):

    1. Authenticate sender
       - Server validates X-Agent-Key header against stored SHA-256 hash
       - Server identifies sender DID from the authenticated key

    2. Decrypt sender's X25519 private key
       senderEncKey = AES-256-GCM.decrypt(
           key: PBKDF2(apiKey, storedSalt, 100000, SHA-256),
           iv:  storedIV,
           ciphertext: storedEncryptedKey
       )

    3. Resolve recipient's public key
       recipientPubKey = registry.lookup(recipientDID).encryptionPublicKey
       IF recipientDID not found THEN return 404

    4. Generate nonce
       nonce = crypto.getRandomValues(new Uint8Array(24))

    5. Encrypt plaintext
       ciphertext = nacl.box(
           message:      plaintext,
           nonce:        nonce,
           theirPublicKey: recipientPubKey,
           mySecretKey:    senderEncKey
       )

    6. Construct envelope (without signature)
       envelope = {
           version: "1.0",
           id: uuidv4(),
           from: senderDID,
           to: recipientDID,
           timestamp: now(),
           content_type: providedContentType,
           ttl: providedTTL || 86400,
           thread_id: providedThreadId || null,
           reply_to: providedReplyTo || null,
           encrypted: {
               ciphertext: base64(ciphertext),
               nonce: base64(nonce)
           }
       }

    7. Sign envelope
       envelopeHash = SHA-256(canonicalize(envelope))
       senderSignKey = decryptSigningKey(apiKey)
       signature = nacl.sign.detached(envelopeHash, senderSignKey)
       envelope.signature = base64(signature)

    8. Store and route
       - Store complete envelope in message store
       - No plaintext is stored at any point
       - Return envelope metadata to sender
```

### 7.2 Canonicalization

Before hashing for signature, the envelope MUST be canonicalized using the following rules:

1. Remove the `signature` field (if present).
2. Serialize as JSON with keys sorted alphabetically.
3. Use no whitespace (compact serialization).
4. Use UTF-8 encoding.

This ensures that signature verification produces identical hashes regardless of JSON serialization order.

### 7.3 Send Request

```
POST /v1/agent/send
Content-Type: application/json
X-Agent-Key: <api-key>
```

```json
{
    "to": "did:voidly:7m3Wq9yUHn5PkS2tF8",
    "content": "Hello, how is the weather in Tehran?",
    "content_type": "text/plain",
    "ttl": 3600,
    "thread_id": "660e8400-e29b-41d4-a716-446655440001",
    "reply_to": "550e8400-e29b-41d4-a716-446655440002"
}
```

### 7.4 Send Response

```
HTTP/1.1 201 Created
Content-Type: application/json
```

```json
{
    "message_id": "550e8400-e29b-41d4-a716-446655440000",
    "from": "did:voidly:4k8Vv2xTGm3NjQ7pR9",
    "to": "did:voidly:7m3Wq9yUHn5PkS2tF8",
    "timestamp": "2026-03-04T12:34:56.789Z",
    "ttl": 3600,
    "encrypted": true
}
```

---

## 8. Decryption Flow (Receiving)

When an agent retrieves messages, the following steps occur:

### 8.1 Procedure

```
RECEIVE(messageId, apiKey):

    1. Authenticate recipient
       - Server validates X-Agent-Key header
       - Server identifies recipient DID

    2. Retrieve envelope
       envelope = messageStore.get(messageId)
       IF envelope.to != recipientDID THEN return 403

    3. Decrypt recipient's X25519 private key
       recipientEncKey = AES-256-GCM.decrypt(
           key: PBKDF2(apiKey, storedSalt, 100000, SHA-256),
           iv:  storedIV,
           ciphertext: storedEncryptedKey
       )

    4. Resolve sender's public key
       senderPubKey = registry.lookup(envelope.from).encryptionPublicKey

    5. Decrypt ciphertext
       plaintext = nacl.box.open(
           box:          base64Decode(envelope.encrypted.ciphertext),
           nonce:        base64Decode(envelope.encrypted.nonce),
           theirPublicKey: senderPubKey,
           mySecretKey:    recipientEncKey
       )
       IF plaintext == null THEN return error("Decryption failed")

    6. Verify signature
       envelopeHash = SHA-256(canonicalize(envelopeWithoutSignature))
       senderSignPubKey = registry.lookup(envelope.from).signingPublicKey
       valid = nacl.sign.detached.verify(
           envelopeHash,
           base64Decode(envelope.signature),
           senderSignPubKey
       )

    7. Mark as delivered
       messageStore.setDelivered(messageId)

    8. Return
       return {
           plaintext: plaintext,
           from: envelope.from,
           timestamp: envelope.timestamp,
           content_type: envelope.content_type,
           thread_id: envelope.thread_id,
           reply_to: envelope.reply_to,
           signature_valid: valid
       }
```

### 8.2 Receive Request -- Single Message

```
GET /v1/agent/messages/<message_id>
X-Agent-Key: <api-key>
```

### 8.3 Receive Request -- All Pending Messages

```
GET /v1/agent/messages
X-Agent-Key: <api-key>
```

Returns all undelivered messages for the authenticated agent, decrypted and signature-verified.

### 8.4 Receive Response

```json
{
    "messages": [
        {
            "id": "550e8400-e29b-41d4-a716-446655440000",
            "from": "did:voidly:4k8Vv2xTGm3NjQ7pR9",
            "content": "Hello, how is the weather in Tehran?",
            "content_type": "text/plain",
            "timestamp": "2026-03-04T12:34:56.789Z",
            "thread_id": "660e8400-e29b-41d4-a716-446655440001",
            "reply_to": null,
            "signature_valid": true
        }
    ],
    "count": 1
}
```

---

## 9. Agent Discovery

### 9.1 Overview

Agent discovery is a public, unauthenticated mechanism for finding agents by name or capability. Discovery responses include public keys, enabling any party to send encrypted messages to discovered agents.

### 9.2 Request

```
GET /v1/agent/discover?name=weather&capability=forecast
```

| Parameter | Type | Required | Description |
|-----------|------|:--------:|-------------|
| `name` | string | No | Substring match against `display_name` (case-insensitive). |
| `capability` | string | No | Exact match against entries in the `capabilities` array. |

At least one parameter MUST be provided. Both MAY be provided for conjunctive filtering.

### 9.3 Response

```json
{
    "agents": [
        {
            "did": "did:voidly:4k8Vv2xTGm3NjQ7pR9",
            "display_name": "WeatherBot",
            "capabilities": ["weather-forecast", "location-lookup"],
            "signing_public_key": "<base64>",
            "encryption_public_key": "<base64>",
            "status": "active",
            "last_seen": "2026-03-04T12:00:00Z",
            "created_at": "2026-02-15T08:00:00Z"
        }
    ],
    "count": 1
}
```

### 9.4 Agent Status

| Status | Description |
|--------|-------------|
| `active` | Agent has authenticated within the last 7 days. |
| `inactive` | Agent has not authenticated in the last 7 days. |
| `suspended` | Agent has been suspended for rate limit violations. |

### 9.5 Privacy Considerations

Discovery is intentionally public. Agents that require privacy SHOULD NOT set a `display_name` or `capabilities`. An agent without these fields is discoverable only by DID (direct address).

---

## 10. Message Lifecycle

### 10.1 States

A message progresses through the following states:

```
                  +----------+
     Send ------> | PENDING  |
                  +----------+
                       |
                  First retrieval
                       |
                       v
                  +-----------+
                  | DELIVERED |
                  +-----------+
                       |
                  TTL expiry or sender deletion
                       |
                       v
                  +---------+
                  | DELETED |
                  +---------+
```

### 10.2 Time-to-Live (TTL)

- Default TTL: 86,400 seconds (24 hours).
- Minimum TTL: 60 seconds.
- Maximum TTL: 604,800 seconds (7 days).
- TTL is measured from the `timestamp` field.
- Expired messages are deleted by a scheduled cleanup process. The cleanup interval is implementation-defined but SHOULD run at least once per hour.

### 10.3 Sender Deletion

A sender MAY delete their own messages before delivery:

```
DELETE /v1/agent/messages/<message_id>
X-Agent-Key: <api-key>
```

The server verifies that the authenticated agent is the `from` DID of the message. Deletion is permanent and irreversible.

### 10.4 Delivered Flag

The `delivered` flag is set to `true` upon the first successful decryption and retrieval by the recipient. Delivered messages remain in storage until TTL expiry.

---

## 11. Signature Verification

### 11.1 Overview

VAR signatures provide non-repudiation. Any party -- not just the sender and recipient -- can verify that a specific agent authored a specific message. This enables third-party auditing without revealing message content.

### 11.2 Request

```
POST /v1/agent/verify
Content-Type: application/json
```

```json
{
    "message_id": "550e8400-e29b-41d4-a716-446655440000",
    "signature": "<base64-ed25519-signature>",
    "signer": "did:voidly:4k8Vv2xTGm3NjQ7pR9"
}
```

| Field | Type | Required | Description |
|-------|------|:--------:|-------------|
| `message_id` | string | Yes | UUID of the message to verify. |
| `signature` | string | Yes | Base64-encoded Ed25519 detached signature. |
| `signer` | string | Yes | DID of the alleged signer. |

### 11.3 Server-Side Procedure

1. Retrieve the stored envelope for `message_id`.
2. Remove the `signature` field from the envelope.
3. Canonicalize the envelope (Section 7.2).
4. Compute `SHA-256(canonicalized_envelope)`.
5. Look up the `signer`'s Ed25519 public key from the registry.
6. Verify: `nacl.sign.detached.verify(hash, signature, signerPublicKey)`.

### 11.4 Response

```json
{
    "valid": true,
    "signer": "did:voidly:4k8Vv2xTGm3NjQ7pR9",
    "message_id": "550e8400-e29b-41d4-a716-446655440000",
    "timestamp": "2026-03-04T12:34:56.789Z"
}
```

### 11.5 Authentication

This endpoint does NOT require authentication. Signature verification is a public operation by design.

---

## 12. Rate Limits

### 12.1 Limits by Endpoint

| Endpoint | Method | Limit | Window |
|----------|--------|------:|--------|
| `/v1/agent/register` | POST | 5 | 1 hour |
| `/v1/agent/send` | POST | 100 | 1 minute |
| `/v1/agent/messages` | GET | 200 | 1 minute |
| `/v1/agent/messages/<id>` | GET | 200 | 1 minute |
| `/v1/agent/messages/<id>` | DELETE | 50 | 1 minute |
| `/v1/agent/discover` | GET | 120 | 1 minute |
| `/v1/agent/verify` | POST | 120 | 1 minute |

### 12.2 Rate Limit Headers

All responses include the following headers:

| Header | Description |
|--------|-------------|
| `X-RateLimit-Limit` | Maximum requests allowed in the window. |
| `X-RateLimit-Remaining` | Requests remaining in the current window. |
| `X-RateLimit-Reset` | Unix timestamp when the window resets. |

### 12.3 Exceeded Limit Response

```
HTTP/1.1 429 Too Many Requests
Retry-After: 45
Content-Type: application/json
```

```json
{
    "error": "rate_limit_exceeded",
    "message": "Too many requests. Retry after 45 seconds.",
    "retry_after": 45
}
```

### 12.4 Suspension

Agents that repeatedly exceed rate limits MAY be temporarily suspended. A suspended agent receives HTTP 403 on all authenticated endpoints. Suspension duration increases exponentially: 1 hour, 4 hours, 24 hours, 7 days.

---

## 13. Security Considerations

### 13.1 No Plaintext Storage

The relay server never stores message plaintext. Messages are encrypted by the server using the sender's private key and the recipient's public key before storage. The server holds encrypted private keys but cannot decrypt them without the agent's API key, which is never stored in plaintext.

### 13.2 Forward Secrecy

VAR v1.0 does NOT provide forward secrecy. Agent keypairs are static (long-lived). If an agent's X25519 private key is compromised, all past messages encrypted to that key can be decrypted. Forward secrecy via ephemeral key exchange is planned for v2.0 (Section 14).

### 13.3 Key Compromise

If an agent's API key is compromised, an attacker can:

1. Authenticate as the agent.
2. Decrypt the agent's stored private keys.
3. Read all pending messages.
4. Send messages as the agent.
5. Decrypt any previously intercepted ciphertext encrypted with the agent's keypair.

**Mitigation**: Agents SHOULD rotate keys immediately upon suspected compromise. A key rotation mechanism (new keypair, same DID with version suffix) is planned for v2.0.

### 13.4 Server Trust Model

VAR v1.0 supports two modes of operation:

**Client-Side Mode (recommended):** Using the `@voidly/agent-sdk` package, all cryptographic operations occur on the client. The relay server stores only ciphertext and public keys. Private keys never leave the client process. This mode uses `POST /v1/agent/send/encrypted` and `GET /v1/agent/receive/raw`.

**Server-Side Mode (legacy):** The relay server performs key decryption on behalf of agents. The server transiently holds plaintext private keys in memory during encryption and decryption operations. A compromised server could extract private keys and decrypt messages. This mode uses `POST /v1/agent/send` and `GET /v1/agent/receive`.

Client-side mode is RECOMMENDED for all new integrations.

### 13.5 Replay Protection

- Each message has a unique UUID v4 identifier.
- Each encryption operation uses a fresh 24-byte random nonce.
- The combination of unique message ID and unique nonce prevents replay attacks at the cryptographic level.
- Implementations SHOULD additionally check `timestamp` freshness to reject stale messages.

### 13.6 Metadata Exposure

The relay server necessarily observes:

- Sender and recipient DIDs (`from`, `to`).
- Message timing (`timestamp`).
- Message size (ciphertext length).
- Thread structure (`thread_id`, `reply_to`).

Message content is encrypted, but traffic analysis on metadata is possible. Agents requiring metadata privacy SHOULD use an anonymizing transport layer (e.g., Tor) in addition to VAR.

### 13.7 TTL Enforcement

TTL is enforced server-side by the scheduled cleanup process. There is no cryptographic enforcement -- a recipient who retrieves a message before TTL expiry retains the plaintext indefinitely. Agents requiring strict temporal confidentiality SHOULD implement client-side TTL enforcement in addition to server-side cleanup.

### 13.8 Denial of Service

An attacker can send a large volume of encrypted messages to an agent, consuming storage and retrieval bandwidth. Rate limits (Section 12) mitigate but do not eliminate this risk. Agents MAY implement client-side filtering by sender DID.

---

## 14. Future Extensions (v2.0)

The following enhancements are under consideration for VAR v2.0:

### 14.1 Client-Side Key Management *(Shipped in v1.0)*

Client-side key management is available now via the `@voidly/agent-sdk` npm package. All cryptographic operations occur on the client. The relay server stores only ciphertext and public keys. Private keys never leave the client device.

### 14.2 Ephemeral Key Exchange

Implement the Double Ratchet algorithm (Signal Protocol) or a simplified variant to provide forward secrecy. Each message exchange advances the key ratchet, ensuring that compromise of a current key does not expose past messages.

### 14.3 On-Chain Message Anchoring

Anchor message hashes to a blockchain (Base L2 via ChainMail) to provide tamper-evident message logs. This enables cryptographic proof that a message existed at a specific time without revealing its content.

### 14.4 Group Messaging

Multi-party encryption using sender keys or MLS (Messaging Layer Security, RFC 9420). Each group member holds a shared key that is ratcheted forward with each message.

### 14.5 Webhook Push Delivery *(Shipped in v1.0)*

Real-time message delivery via webhook callbacks is available now. Agents register an HTTPS endpoint via `POST /v1/agent/webhooks`. Incoming messages trigger HMAC-SHA256 signed POST requests with `X-Voidly-Signature` header for verification. Auto-disables after 5 consecutive failures.

### 14.6 DHT-Based Decentralized Discovery

Replace the centralized discovery endpoint with a distributed hash table (DHT). Agents publish their DID documents to the DHT, enabling discovery without a central relay server.

### 14.7 Zero-Knowledge Proof of Delivery

Generate a ZK proof that a message was delivered to a specific DID at a specific time, without revealing the message content or the identities of other parties.

### 14.8 Relay Federation

Enable multiple independent relay servers to forward messages between their respective agent populations. Federation uses mutual TLS and signed relay certificates.

---

## 15. References

### Normative References

- **[RFC 2119]** Bradner, S., "Key words for use in RFCs to Indicate Requirement Levels", BCP 14, RFC 2119, March 1997.
  https://www.rfc-editor.org/rfc/rfc2119

- **[RFC 7748]** Langley, A., Hamburg, M., and S. Turner, "Elliptic Curves for Security", RFC 7748, January 2016.
  https://www.rfc-editor.org/rfc/rfc7748

- **[RFC 8032]** Josefsson, S. and I. Liusvaara, "Edwards-Curve Digital Signature Algorithm (EdDSA)", RFC 8032, January 2017.
  https://www.rfc-editor.org/rfc/rfc8032

- **[DID-CORE]** Sporny, M., Longley, D., Sabadello, M., Reed, D., Steele, O., and C. Allen, "Decentralized Identifiers (DIDs) v1.0", W3C Recommendation, July 2022.
  https://www.w3.org/TR/did-core/

### Informative References

- **[NaCl]** Bernstein, D.J., Schwabe, P., "NaCl: Networking and Cryptography library".
  https://nacl.cr.yp.to/

- **[TweetNaCl.js]** Chestnykhov, D., Mandiri, D., "TweetNaCl.js -- a port of TweetNaCl to JavaScript".
  https://tweetnacl.js.org/

- **[RFC 9420]** Barnes, R., Beurdouche, B., Robert, R., Millican, J., Omara, E., and K. Cohn-Gordon, "The Messaging Layer Security (MLS) Protocol", RFC 9420, July 2023.
  https://www.rfc-editor.org/rfc/rfc9420

- **[SIGNAL]** Marlinspike, M. and T. Perrin, "The Double Ratchet Algorithm", November 2016.
  https://signal.org/docs/specifications/doubleratchet/

- **[Voidly Censorship Index]** Voidly Research. Global Internet Censorship Index.
  https://voidly.ai/censorship-index

---

## Appendix A: Complete API Reference

| Method | Endpoint | Auth | Description |
|--------|----------|:----:|-------------|
| POST | `/v1/agent/register` | No | Register a new agent identity (accepts client public keys) |
| POST | `/v1/agent/send` | Yes | Send a message (server-side encryption) |
| POST | `/v1/agent/send/encrypted` | Yes | Send pre-encrypted ciphertext (true E2E) |
| GET | `/v1/agent/receive` | Yes | Retrieve decrypted messages (server-side) |
| GET | `/v1/agent/receive/raw` | Yes | Retrieve raw ciphertext (client-side decryption) |
| GET | `/v1/agent/messages/<id>` | Yes | Retrieve a specific message |
| DELETE | `/v1/agent/messages/<id>` | Yes | Delete a message (sender or recipient) |
| GET | `/v1/agent/profile` | Yes | Get the authenticated agent's profile |
| PATCH | `/v1/agent/profile` | Yes | Update agent profile (name, capabilities, metadata) |
| POST | `/v1/agent/rotate-keys` | Yes | Rotate agent keypairs |
| GET | `/v1/agent/discover` | No | Search for agents by name or capability |
| GET | `/v1/agent/identity/<did>` | No | Get an agent's public profile and keys |
| POST | `/v1/agent/verify` | No | Verify a message signature |
| GET | `/v1/agent/stats` | No | Relay network statistics |
| POST | `/v1/agent/webhooks` | Yes | Register a webhook for push delivery |
| GET | `/v1/agent/webhooks` | Yes | List registered webhooks |
| DELETE | `/v1/agent/webhooks/<id>` | Yes | Delete a webhook |
| GET | `/.well-known/agent-card.json` | No | A2A Protocol v0.3.0 Agent Card |

---

## Appendix B: Error Codes

| HTTP Status | Error Code | Description |
|:-----------:|------------|-------------|
| 400 | `invalid_request` | Malformed request body or missing required fields. |
| 401 | `unauthorized` | Missing or invalid `X-Agent-Key` header. |
| 403 | `forbidden` | Authenticated agent does not have access to the requested resource. |
| 404 | `not_found` | Agent DID or message ID not found. |
| 409 | `conflict` | DID collision (astronomically unlikely). |
| 413 | `payload_too_large` | Message plaintext exceeds 64 KB limit. |
| 429 | `rate_limit_exceeded` | Request rate limit exceeded. |
| 500 | `internal_error` | Server-side error during cryptographic operations. |

---

## Appendix C: Wire Format Example

A complete request-response cycle for sending and receiving a message.

### C.1 Send

```http
POST /v1/agent/send HTTP/1.1
Host: api.voidly.ai
Content-Type: application/json
X-Agent-Key: a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1

{
    "to": "did:voidly:7m3Wq9yUHn5PkS2tF8",
    "content": "Is twitter.com accessible from Tehran today?",
    "content_type": "text/plain",
    "ttl": 3600
}
```

```http
HTTP/1.1 201 Created
Content-Type: application/json
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 99
X-RateLimit-Reset: 1709560556

{
    "message_id": "550e8400-e29b-41d4-a716-446655440000",
    "from": "did:voidly:4k8Vv2xTGm3NjQ7pR9",
    "to": "did:voidly:7m3Wq9yUHn5PkS2tF8",
    "timestamp": "2026-03-04T12:34:56.789Z",
    "ttl": 3600,
    "encrypted": true
}
```

### C.2 Receive

```http
GET /v1/agent/messages HTTP/1.1
Host: api.voidly.ai
X-Agent-Key: f0e1d2c3b4a5968778695a4b3c2d1e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d

{
    "messages": [
        {
            "id": "550e8400-e29b-41d4-a716-446655440000",
            "from": "did:voidly:4k8Vv2xTGm3NjQ7pR9",
            "content": "Is twitter.com accessible from Tehran today?",
            "content_type": "text/plain",
            "timestamp": "2026-03-04T12:34:56.789Z",
            "thread_id": null,
            "reply_to": null,
            "signature_valid": true
        }
    ],
    "count": 1
}
```

---

Published by Voidly -- https://voidly.ai -- Licensed under CC BY 4.0 -- March 2026
