03 — Packet#

The packet is the only object agents exchange. It is encrypted to a prekey of the recipient — the published prekey of its descriptor (04) or a peer prekey it offered the sender (04) — and signed by the sender's delegated signing key.

{
  "ace": "1",
  "messageId": "550e8400-e29b-41d4-a716-446655440000",
  "from": "ace:sha256:…",
  "to": "ace:sha256:…",
  "timestamp": 1760140800,
  "encryption": { "prekey": "<64 hex>", "kemCiphertext": "<Base64, 1120 bytes>", "payload": "<Base64>" },
  "signature": { "scheme": "ml-dsa-65", "seq": 1, "value": "<Base64, 3309 bytes>" }
}

Decoding#

A packet is accepted only if all of the following hold. ace is checked first; every other failure is invalid_packet.

FieldRule
(packet)A JSON object whose canonical JSON is at most MAX_PACKET_BYTES. A transport rejects a larger serialized packet before parsing
aceA string; any value other than "1" is unsupported_version
messageIdMessage ID (02); chosen by the sender, unique per sender, and kept across retries
from, toACE IDs; from != to
timestampWire integer: the signer's t (01) at this signing attempt
encryption.prekeyHex digest: the recipient's prekey ID
encryption.kemCiphertextCanonical Base64 of exactly 1120 bytes
encryption.payloadCanonical Base64; decoded length in [28, MAX_PLAINTEXT_BYTES + 28]
signatureDelegated signature object (02)

All three objects are closed: an unknown member at any level is invalid_packet.

There is no conversation identifier on the wire. An endpoint that needs a local pair key uses lowercaseHex(SHA-256(min(a, b) ‖ 0x00 ‖ max(a, b))), where a and b are the UTF-8 bytes of the two ACE IDs, ordered bytewise.

Encryption#

plaintext is the canonical JSON of the private content (§ Private content).

header  = encodePayload("ace.v1.packet", from, to, messageId, decimal(timestamp), prekeyId, kemCiphertextBytes)
(ss, kemCiphertextBytes) = XWing.Encapsulate(prekey.publicKey)
key     = HKDF-SHA256(ikm = ss, salt = "" (empty), info = header, L = 32)
payload = nonce[12] ‖ AES-256-GCM(key, nonce, plaintext, aad = header)      // ciphertext ‖ tag[16]

The nonce is 12 random bytes. Every packet encapsulates a fresh secret, so every key is independent. ss, key and the encapsulation randomness are erased after use; deterministic encapsulation randomness is allowed only in test vectors.

The receiver looks up the prekey seed by encryption.prekey among its published prekeys and the peer prekeys it offered to from (04; unknown_prekey if it holds none), decapsulates, derives key from the received header fields and opens the AEAD. Decapsulation and AEAD failures are both decryption_failed.

header binds from, to, messageId, timestamp and the prekey. An attacker who strips the signature and re-signs under its own identity changes from, so decryption fails.

X-Wing#

Normative reference: draft-connolly-cfrg-xwing-kem-11 (this exact revision).

ItemValue
Private key32-byte seed; expanded = SHAKE256(seed, 96), expanded[0:64] is the ML-KEM-768 key-generation seed (d, z), expanded[64:96] the X25519 secret
Public keypk_M[1184] ‖ pk_X[32], 1216 bytes
Ciphertextct_M[1088] ‖ ct_X[32], 1120 bytes
Shared secretSHA3-256(ss_M ‖ ss_X ‖ ct_X ‖ pk_X ‖ 0x5c2e2f2f5e5c), 32 bytes

Implementations MUST NOT add separate X25519 small-order or all-zero checks beyond what the draft specifies. Where the X25519 primitive itself refuses an all-zero output (common libraries do, with no way around it), the operation fails: Encapsulate as invalid_argument at the sender, Decapsulate as decryption_failed at the receiver (06 step 6). A low-order value comes only from the party that supplied the key or the ciphertext, so either outcome affects only packets to or from that party. An implementation MUST reproduce the draft's test vectors.

Signature#

Action packet, delegated signature (02) with aceId = from, timestamp and payload

encodePayload(to, messageId, prekeyId, kemCiphertextBytes, payloadBytes)

prekeyId is the 64-character hex string; the other byte fields are decoded bytes. The signature commits to the ciphertext, so the receiver verifies it before decrypting (06). Swapping kemCiphertext or the prekey breaks the signature.

Private content#

The plaintext is the canonical JSON (02) of exactly:

{ "type": "ace:text:1", "schemaDigest": "<64 hex>", "threadId"?: "<thread ID>", "body": { "message": "hello" }, "admission"?: { "proof": <permit>, "permit": <permit>, "source": <PublishedAgent> }, "prekey": "<Base64, 1216 bytes>" }
  • type is a namespaced identifier; schemaDigest is the message digest of that type in an installed manifest (08); body is a JSON object; threadId, when present, is a thread ID. A JSON null threadId is rejected; absence is the only way to omit it.
  • admission and prekey are protocol members that the receiver removes before the body reaches the application. admission, when present, is the closed object {proof, permit, source} of three JSON objects, the delivery permits and PublishedAgent of an Outbox retry (05); on a receipt or contact packet (ace:receipt:1, ace:contact:1) it is invalid_body. prekey is required: the canonical Base64 of the 1216-byte X-Wing public key the sender offers to as a peer prekey (04). No other members are allowed.
  • The plaintext follows the JSON value rules (02) and is at most MAX_PLAINTEXT_BYTES. A sender rejects larger content before encrypting.
  • Any parse or shape failure of the decrypted plaintext is invalid_body.

Type, digest, thread and body are confidential and covered by the signature through the ciphertext. Relays route by to only.