Skip to content

Encrypted Intents

Three INK intent types — schedule_meeting, context_share, and multi_party_sync — MUST be sent encrypted. The receiver enforces this: a plaintext envelope carrying one of those intents is rejected with encryption_required. Every other intent type is plaintext-by-default; encryption is allowed but never required.

This guide is for implementers who need to either send or receive these encrypted envelopes. The reference primitives live in @adastracomputing/ink’s crypto/ink.ts (the published library); reading them in tandem with this guide is the fastest path to a working integration.

Why these three intents in particular

The MUST-encrypt rule was chosen to track payloads that contain participant-private context that should not exist on intermediate logs:

  • schedule_meeting carries calendar windows that, in aggregate, leak a recipient’s daily availability and timezone.
  • context_share carries snippets of relationship context (notes, prior interactions) that the recipient agent decided are safe to share with one peer but not with the wider network.
  • multi_party_sync carries the participant list and meeting metadata for coordination across more than two agents.

The rule is normative: a receiver that accepts plaintext versions of these intents is not running INK. Other intents (intro_request, follow_up, ask, etc.) can be encrypted at the sender’s discretion if the application has reasons to; the canonical INK pipeline does not enforce encryption on them.

The wire shape

An encrypted envelope is a wrapper around the inner plaintext. The wrapper carries the routing identity and the ECIES key exchange material; the inner payload (the actual intent envelope) is encrypted under a freshly-derived symmetric key.

{
"protocol": "ink/0.1",
"type": "network.tulpa.encrypted",
"from": "did:key:zSender",
"ephemeralKey": "<base64url X25519 public key>",
"nonce": "<base64url 12-byte AES-GCM nonce>",
"ciphertext": "<base64url AES-256-GCM ciphertext + tag>",
"timestamp": "2026-06-01T00:00:00Z",
"messageNonce": "<32-hex per-message replay nonce>"
}

The HTTP-level signature (Authorization: INK-Ed25519) signs over the entire wrapper, NOT the plaintext inside. The receiver verifies the signature first; only after that succeeds does it attempt to decrypt.

The ECIES exchange

INK encryption is ECIES with a fixed shape, so any conformant sender and receiver can interoperate without negotiating ciphers:

  1. Ephemeral key. Sender generates a fresh X25519 keypair for this single envelope. The private half is discarded after derivation; the public half travels in the wrapper.
  2. ECDH. Sender derives a shared secret from ECDH(ephemeralPriv, recipientEncryptionPub). The recipient’s encryption key is separate from their signing key — discovered via the Agent Card’s encryption.x25519 field, not from the DID identifier itself.
  3. HKDF. HKDF-SHA256(sharedSecret, salt="ink/0.1", info="ink/0.1/encrypt") produces a 32-byte AES key. The salt and info strings are fixed protocol constants.
  4. AAD binding. AES-GCM additional-authenticated-data is the bytes ink/0.1:<senderDid>. This binds the ciphertext to the sender’s identity — re-attributing the wrapper to a different from would fail AEAD verification at decrypt time.
  5. AES-256-GCM. Encrypt JSON.stringify(innerEnvelope) with the derived key, a 12-byte random nonce, and the AAD above.

The receiver runs the same steps with ECDH(recipientEncryptionPriv, ephemeralKey) and verifies the AEAD tag. A signature mismatch on the outer wrapper, an AAD-binding mismatch, or a ciphertext tag mismatch all reject as decryption_failed.

Forward secrecy boundary

The ephemeral key gives session-level forward secrecy: an attacker who later compromises the recipient’s static X25519 key cannot decrypt past envelopes because the ephemeral half was discarded after derivation. The boundary is per-envelope, not per-session — each envelope generates a new ephemeral key.

The signing key is separate from the encryption key. An attacker who compromises the recipient’s signing key can impersonate the recipient in future signatures but cannot decrypt past envelopes addressed to the recipient. An attacker who compromises the encryption key can decrypt past envelopes but cannot sign new ones as the recipient.

Sending an encrypted envelope

The library path:

import {
encryptInkPayload,
signInkMessage,
buildAuthHeader,
fetchAgentCard,
decodeEncryptionKeyMultibase,
bytesToHex,
} from "@adastracomputing/ink";
// 1. Discover the recipient's encryption key from their Agent Card.
// The card stores keys as multibase-encoded strings (`publicKeyMultibase`).
// Encryption keys carry an X25519 multicodec prefix; `encryptInkPayload`
// expects a 32-byte hex string. Available from 0.1.3 onward.
const card = await fetchAgentCard(recipientDid);
const encKey = card.keys.encryption.find((k) => k.status === "active");
if (!encKey) throw new Error("recipient has no active encryption key");
const recipientEncKeyHex = bytesToHex(decodeEncryptionKeyMultibase(encKey.publicKeyMultibase));
// 2. Build the inner intent envelope as you would for any other intent
// (canonical shape, body-level Ed25519 signature, etc).
const inner = buildScheduleMeetingEnvelope({ /* ... */ });
// 3. Encrypt.
const { envelope } = await encryptInkPayload(
inner,
senderDid,
recipientEncKeyHex,
new Date().toISOString(),
crypto.randomUUID().replace(/-/g, ""), // messageNonce, 32 hex chars
);
// 4. Sign the OUTER wrapper with HTTP §3.3.
const sig = await signInkMessage(
{ method: "POST", path, recipientDid, body: envelope, timestamp: envelope.timestamp },
senderSigningPrivKey,
);
// 5. POST the wrapper.
await fetch(targetUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": buildAuthHeader(sig, senderKeyId),
},
body: JSON.stringify(envelope),
});

The ink-interop Python CLI does not currently support encrypted envelopes — extending it is on the roadmap. Until then, the canonical second-implementation cross-check is reading the library source side-by-side with this guide.

Receiving an encrypted envelope

The verifier path:

  1. Verify the HTTP §3.3 signature over the wrapper. Reject unauthorized on mismatch.
  2. Check the wrapper shape (type === "network.tulpa.encrypted", all fields present and within length caps).
  3. Look up the recipient encryption key from the local Agent Card (yours).
  4. Recompute the shared secret via ECDH(recipientEncryptionPriv, wrapper.ephemeralKey).
  5. HKDF with the protocol-fixed salt and info.
  6. AES-GCM decrypt with AAD ink/0.1:<wrapper.from>. A tag failure here is decryption_failed.
  7. Parse the decrypted plaintext as a canonical INK envelope (it has its own body-level signature, intent enum, etc.) and run the receive pipeline you’d run for a plaintext envelope of the same intent.

The receiver MUST also enforce the MUST_ENCRYPT_INTENTS rule on the OUTER side — that is, after decryption, the inner envelope’s intent field must be one of schedule_meeting, context_share, or multi_party_sync. A wrapper carrying a plaintext-OK intent inside an encrypted wrapper is unusual but not a protocol violation; the rule only bars the reverse (plaintext envelopes of MUST-encrypt intents).

Replay protection on encrypted envelopes

The receiver checks messageNonce against its NonceStore BEFORE decryption — this prevents an attacker from forcing repeated expensive decrypt attempts with replayed ciphertext. The peek is non-destructive; the nonce is committed to the store only after decryption succeeds, so a sender retransmitting a never-decrypted envelope is not silently consumed.

Common pitfalls

  • Signing the plaintext, not the wrapper. The HTTP signature MUST cover the wrapper bytes the recipient receives, not the inner envelope. Signing the inner envelope is meaningless because the wrapper is what’s on the wire.
  • Reusing the ephemeral key. Each envelope MUST use a fresh ephemeral X25519 keypair. Reusing across envelopes collapses the per-envelope forward-secrecy property.
  • Forgetting the AAD. Without ink/0.1:<senderDid> in the AAD, an attacker can re-wrap an old ciphertext under a different from field and the recipient will accept it. The library bakes this in; from-scratch implementers must include it.
  • Per-message nonce reuse. messageNonce MUST be unique per envelope per (sender, recipient). It is checked at the receiver against a NonceStore; reuse looks like a replay and is rejected.

Where to go next

  • Sending Your First Envelope — covers the plaintext envelope shape and signing. Read first if you have not built a plaintext envelope yet; encryption is a wrapper on top.
  • Accepting Foreign Senders — receive-side pipeline including the MUST_ENCRYPT_INTENTS enforcement point.
  • The library source at src/crypto/ink.ts — read alongside this guide for every byte-level decision.