Encryption
For sensitive payloads (e.g., scheduling details, personal context), INK supports ECIES encryption.
Encryption Flow
Encryption Procedure
- Sender generates an ephemeral X25519 key pair for this message. The sender’s long-term
encryptionKeyMultibaseis NOT used for ECDH; a fresh ephemeral gives each message an independent sender contribution. This is ephemeral-static ECIES against the recipient’s static key, so it does not provide forward secrecy (see the note under Encrypted Intents). - Sender performs ECDH using the ephemeral private key and the recipient’s static X25519 public key, taken from the
keys.encryptionentry named bycurrentEncryptionKeyIdon the recipient’s Agent Card. - Derives a symmetric key via HKDF-SHA256:
- IKM: The raw ECDH shared secret (32 bytes).
- Salt:
"ink/0.1"(UTF-8 encoded, 7 bytes). - Info:
"ink/0.1/encrypt"(UTF-8 encoded, 15 bytes). - Output length: 32 bytes.
- Encrypts the plaintext envelope with AES-256-GCM using a random 12-byte nonce.
- Wraps the result in an
InkEncryptedEnvelopeouter envelope.
Outer Envelope
{ "protocol": "ink/0.1", "type": "network.tulpa.encrypted", "from": "did:plc:sender", "ephemeralKey": "<base64url-encoded ephemeral X25519 public key>", "nonce": "<base64url-encoded 12-byte AES-GCM nonce>", "ciphertext": "<base64url-encoded AES-GCM ciphertext + 16-byte auth tag>", "timestamp": "2026-03-18T12:00:00Z", "messageNonce": "<replay-protection nonce, 16 to 256 characters of [A-Za-z0-9_-]>"}The outer envelope is not encrypted. from, ephemeralKey, timestamp and messageNonce are plaintext. This is necessary so the recipient can identify the sender and apply replay protection before decryption.
Plaintext Envelope
The ciphertext, when decrypted, yields a JSON object identical to an unencrypted INK intent message. An intent envelope has no type field; it carries id, correlationId, createdAt, intent and a nested payload under the strict per-intent schema:
{ "protocol": "ink/0.1", "id": "01JQ8Z5N3K7M9V4P2C6R8T0W1Y", "correlationId": "01JQ8Z5N3K7M9V4P2C6R8T0W1Z", "createdAt": "2026-03-18T12:00:00.000Z", "from": "did:plc:sender", "to": "did:plc:recipient", "intent": "schedule_meeting", "payload": { "proposedTimes": ["2026-03-20T15:00:00.000Z", "2026-03-20T16:00:00.000Z"], "topic": "Discuss partnership opportunity", "format": "video", "urgency": "normal" }, "signature": "<base64url body signature>", "nonce": "<base64url>", "timestamp": "2026-03-18T12:00:00.000Z"}The recipient MUST verify that from and to in the plaintext envelope match the outer envelope’s from and the recipient’s own DID. A mismatch indicates tampering and MUST be rejected.
The same binding is a producer obligation. A conformant sealer MUST NOT emit an envelope its own decrypt rule would reject, so it refuses to encrypt a plaintext whose from is not the outer envelope sender, or whose to is absent, empty or different from the recipient identity the caller asserted. encryptInkPayload and its Go counterpart EncryptInkPayload both enforce this at seal time. An implementation that checks the binding only on receive can still mint envelopes nobody will open.
Decryption Procedure
- Parse the outer envelope. Verify the
Authorizationheader signature. Reject before decryption if invalid. - Check
timestampandmessageNonceagainst replay protection rules. Reject before decryption if replayed. - Perform ECDH using the recipient’s own X25519 private key and the
ephemeralKey. - Derive the symmetric key via HKDF-SHA256.
- Decrypt
ciphertextusing AES-256-GCM. - Parse the plaintext envelope. Verify
frommatches outerfromandtomatches recipient’s DID. - Process the inner message normally.
Envelope Anatomy: Plaintext vs Encrypted
A side-by-side comparison of what is visible on the wire in each mode.
Key differences:
- Encrypted envelopes expose only:
from,ephemeralKey,nonce,timestamp,messageNonce - The
tofield, intent type, purpose and all payload data are inside the ciphertext - Both modes carry Ed25519 signatures and replay protection
- The outer
nonceis the AES-GCM IV (12 bytes, base64url-encoded);messageNonceis the replay-protection nonce. Both fields live on the outer envelope.
AAD Construction
AES-GCM Additional Authenticated Data binds the ciphertext to every security-relevant outer-envelope field. Without this binding, an attacker could replay the same ciphertext under a different sender, recipient, timestamp or message type. The library constructs AAD as follows:
- Build an object with exactly these members:
protocol("ink/0.1"),type,from,recipientKey,ephemeralKey,nonce,timestamp,messageNonce.recipientKeyis the base64url of the recipient’s static X25519 public key;ephemeralKeyandnonceare the base64url outer-envelope values. Every other value must equal its outer-envelope value byte-for-byte. BindingrecipientKeyties the ciphertext to one recipient: the decrypter recomputes it from its own private key, so a ciphertext encrypted for a different recipient fails the tag. - JCS-canonicalize the object (sorts keys lexicographically), so the on-wire member order does not matter.
- Prepend the domain separator
ink/0.1:envelope\n. - UTF-8-encode the result. The resulting bytes are passed as
additionalDatatoAES-GCM.encrypt/AES-GCM.decrypt.
Implementations MUST construct AAD identically on both sides. Any mismatch causes the GCM tag to fail and decryption returns an error. There is no version negotiation for AAD.
Encryption Requirements by Intent Type
| Intent Type | Sender | Receiver | Rationale |
|---|---|---|---|
schedule_meeting | MUST encrypt | MUST reject plaintext | Contains availability windows, calendar data |
context_share | MUST encrypt | MUST reject plaintext | Contains personal/professional context |
multi_party_sync | MUST encrypt | MUST reject plaintext | Contains scheduling coordination data for multi-party enclaves |
intro_request | MAY encrypt | MUST accept both | Low sensitivity |
opportunity | MAY encrypt | MUST accept both | Low sensitivity |
follow_up | SHOULD encrypt | MUST accept both | May reference prior conversations |
ask | MAY encrypt | MUST accept both | General-purpose |