DCTRL-0002Cryptographic Operations
Ed25519 signatures, X25519 key agreement, AES-256-GCM encryption, canonical JSON, and encoding formats.
| Field | Value |
|---|---|
| RFC | DCTRL-0002 |
| Title | Cryptographic Operations |
| Status | Draft |
| Created | 2026-03-14 |
| Version | 0.1 |
| Requires | — |
| Required By | DCTRL-0001, DCTRL-0003, DCTRL-0004, DCTRL-0005, DCTRL-0006 |
Abstract
This document specifies the cryptographic primitives, key structures, encoding formats, and canonical serialization used throughout the Decentrl Protocol. All other DCTRL specifications depend on the operations defined here.
Status of This Document
This is a draft specification.
Table of Contents
- Introduction
- Terminology
- Identity Key Structure
- Ed25519 Digital Signatures
- X25519 Key Agreement
- AES-256-GCM Authenticated Encryption
- Canonical JSON Serialization
- JSON Object Signing
- Multibase Encoding
- Base64 Encoding
- Encrypted Tags
- Contract Key Epochs
- Security Considerations
- References
1. Introduction
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].
The Decentrl Protocol uses three cryptographic primitives from the Curve25519 family:
- Ed25519 for digital signatures (authentication, non-repudiation)
- X25519 for key agreement (deriving shared secrets)
- AES-256-GCM for authenticated encryption (confidentiality + integrity)
This specification defines how these primitives are used, how data is serialized for signing, and how keys and ciphertext are encoded for transport.
2. Terminology
Signing Key — An Ed25519 key pair. The public key is 32 bytes. The private key is 32 bytes (the seed).
Pre-Key — An X25519 key pair used for bootstrapping communication contracts. Published in the DID document.
Storage Key — A 256-bit AES symmetric key for encrypting events at rest. Never published or shared.
Ephemeral Key — An X25519 key pair generated for a single communication contract. The private half is wiped once the root secret has been derived from it.
Root Secret — A 32-byte shared secret derived via X25519 key agreement between two ephemeral keys. Used exactly once, as HKDF input for the contract's first chain key [Section 12], and then wiped. It is never used as an encryption key and never stored.
Chain Key — A 32-byte secret belonging to one epoch of a contract. Each chain key is derived one-way from the previous one [Section 12].
Epoch Key — The 32-byte AES-256-GCM key for one epoch of a contract, derived from that epoch's chain key [Section 12].
Canonical JSON — A deterministic JSON serialization produced by deep-sorting all object keys alphabetically.
3. Identity Key Structure
Each Decentrl identity MUST generate three independent keys at creation time:
| Key | Algorithm | Size | Published | Purpose |
|---|---|---|---|---|
| Signing key | Ed25519 | 32-byte private + 32-byte public | Public key in DID | Authentication, signatures |
| Pre-key | X25519 | 32-byte private + 32-byte public | Public key in DID | Bootstrapping new contracts |
| Storage key | AES-256 | 32 bytes | Never | Self-encryption of stored events; tag key derivation |
3.1 Key Generation
signing_private = secure_random_bytes(32)
signing_public = Ed25519_public_from_private(signing_private)
pre_key_private = secure_random_bytes(32)
pre_key_public = X25519_public_from_private(pre_key_private)
storage_key = secure_random_bytes(32)All random byte generation MUST use a cryptographically secure random number generator (CSPRNG).
3.2 Key Separation
Each key has a single purpose. Implementations MUST NOT:
- Use the signing key for encryption or key agreement
- Use the pre-key for signing or ongoing encryption
- Use the storage key for transit encryption
- Derive any key from another key in the identity key structure, except the tag key, which is derived from the storage key via HKDF with a dedicated
infolabel [Section 11.2]
3.3 Key Storage
The signing private key, pre-key private key, and storage key MUST be stored securely on the local device.
The storage key MUST NOT be transmitted over the network, published in any document, or shared with any other party under any circumstances.
Ephemeral private keys generated for communication contracts MUST be encrypted with the storage key before being persisted to disk.
4. Ed25519 Digital Signatures
4.1 Algorithm
The Decentrl Protocol uses Ed25519 as specified in [RFC 8032]. Ed25519 provides:
- 128-bit security level
- 64-byte signatures
- 32-byte public keys
- 32-byte private keys (seeds)
4.2 Signing
Input: A message (byte array) and a 32-byte Ed25519 private key.
Output: A 64-byte signature.
signature = Ed25519_sign(message, private_key)The message MUST be the complete byte representation of the data being signed. For JSON objects, the message is produced by the canonical JSON serialization defined in Section 7.
4.3 Verification
Input: A 64-byte signature, a message (byte array), and a 32-byte Ed25519 public key.
Output: Boolean (valid or invalid).
valid = Ed25519_verify(signature, message, public_key)Implementations MUST reject signatures that fail verification. A failed verification indicates either message tampering, an incorrect public key, or an invalid signature.
4.4 Signature Encoding
When signatures are included in JSON messages, they MUST be encoded as base64 strings:
signature_string = base64_encode(signature_bytes)When verifying, the base64 string MUST be decoded back to bytes before verification:
signature_bytes = base64_decode(signature_string)5. X25519 Key Agreement
5.1 Algorithm
The Decentrl Protocol uses X25519 as specified in [RFC 7748]. X25519 provides elliptic curve Diffie-Hellman key agreement over Curve25519.
5.2 Key Generation
private_key = secure_random_bytes(32)
public_key = X25519_public_from_private(private_key)5.3 Shared Secret Derivation
Input: A 32-byte X25519 private key and a 32-byte X25519 public key (from the counterpart).
Output: A 32-byte shared secret.
shared_secret = X25519(own_private_key, counterpart_public_key)The X25519 function is commutative:
X25519(alice_private, bob_public) == X25519(bob_private, alice_public)This property enables both parties to independently derive the same shared secret without transporting any secret over the network.
5.4 Usage in the Protocol
X25519 key agreement is used in two contexts:
-
Contract request wrapping:
X25519(initiator_ephemeral_private, recipient_pre_key_public)— encrypts the contract request so only the recipient can read it. -
Root secret derivation:
X25519(own_ephemeral_private, counterpart_ephemeral_public)— derives a contract's root secret, the input from which its epoch keys are derived [Section 12].
Implementations MUST NOT use X25519 for any other purpose.
6. AES-256-GCM Authenticated Encryption
6.1 Algorithm
The Decentrl Protocol uses AES-256-GCM (Galois/Counter Mode) for authenticated encryption. AES-GCM provides both confidentiality and integrity in a single operation.
6.2 Parameters
| Parameter | Size | Source |
|---|---|---|
| Key | 256 bits (32 bytes) | Epoch key [Section 12], storage key, or contract request wrapping key [Section 5.4] |
| Nonce | 96 bits (12 bytes) | Fresh random per operation |
| Authentication tag | 128 bits (16 bytes) | Produced by encryption |
6.3 Encryption
Input: Plaintext string and a 32-byte key.
Output: Base64-encoded ciphertext blob.
function encrypt(plaintext_string, key):
plaintext_bytes = UTF8_encode(plaintext_string)
nonce = secure_random_bytes(12) // MUST be fresh for every encryption
(ciphertext, tag) = AES_256_GCM_encrypt(plaintext_bytes, key, nonce)
// Concatenate: nonce || ciphertext || tag
combined = nonce(12 bytes) || ciphertext(N bytes) || tag(16 bytes)
return base64_encode(combined)The nonce MUST be generated using a CSPRNG. Implementations MUST NOT reuse nonces with the same key. A nonce collision under the same key breaks AES-GCM's confidentiality and authenticity guarantees.
6.4 Decryption
Input: Base64-encoded ciphertext blob and a 32-byte key.
Output: Plaintext string, or an error if authentication fails.
function decrypt(encoded_blob, key):
combined = base64_decode(encoded_blob)
nonce = combined[0..12] // first 12 bytes
tag = combined[combined.length-16..] // last 16 bytes
ciphertext = combined[12..combined.length-16] // everything in between
plaintext_bytes = AES_256_GCM_decrypt(ciphertext, key, nonce, tag)
// If authentication fails → return error
return UTF8_decode(plaintext_bytes)Implementations MUST verify the authentication tag during decryption. If the tag does not match, decryption MUST fail and the plaintext MUST NOT be returned.
6.5 Nonce Safety
AES-GCM with random 96-bit nonces has a birthday bound of approximately 2^48 encryptions per key before the probability of a nonce collision becomes non-negligible.
In the Decentrl Protocol, contract traffic keys change every day: each epoch of a contract has its own epoch key [Section 12]. One day of traffic between two parties stays far below the safety bound.
Implementations SHOULD enforce that no more than 2^32 encryptions are performed under a single key as a conservative margin.
7. Canonical JSON Serialization
7.1 Purpose
The Decentrl Protocol requires deterministic JSON serialization for all signature operations. Two independent implementations given the same logical JSON object MUST produce identical byte sequences to ensure signature compatibility.
7.2 Algorithm
Input: A JSON-serializable object (may contain nested objects and arrays).
Output: A UTF-8 byte array.
function canonical_json(object):
sorted = deep_sort_keys(object)
json_string = JSON.stringify(sorted) // standard serialization, no whitespace
return UTF8_encode(json_string)7.3 Key Sorting
All object keys MUST be sorted alphabetically (Unicode code point order) at every level of nesting.
The sorting MUST be applied recursively:
- Objects: Sort keys alphabetically. Apply sorting recursively to all values that are objects.
- Arrays: Do NOT reorder array elements. Apply sorting recursively to any elements that are objects.
- Primitives: (strings, numbers, booleans, null) — no transformation.
Example:
Input:
{"b": 1, "a": {"d": 3, "c": 2}}After deep sort:
{"a": {"c": 2, "d": 3}, "b": 1}7.4 Serialization
After sorting, the object MUST be serialized using standard JSON serialization with:
- No whitespace between tokens
- No trailing newline
- Standard JSON escaping for strings
- Numbers serialized in their canonical form (no leading zeros, no trailing zeros after decimal point unless the implementation's JSON.stringify produces them)
7.5 Encoding
The resulting JSON string MUST be encoded as UTF-8 bytes. The UTF-8 byte array is the message that is signed or verified.
8. JSON Object Signing
8.1 Signing a JSON Object
This is the primary signing operation used throughout the protocol for commands and events.
Input: A JSON object and a 32-byte Ed25519 private key.
Output: A base64-encoded signature string.
function sign_json_object(object, private_key):
message = canonical_json(object) // Section 7
signature = Ed25519_sign(message, private_key) // Section 4
return base64_encode(signature)8.2 Verifying a JSON Object Signature
Input: A JSON object, a base64-encoded signature string, and a 32-byte Ed25519 public key.
Output: Boolean.
function verify_json_signature(object, signature_string, public_key):
message = canonical_json(object)
signature = base64_decode(signature_string)
return Ed25519_verify(signature, message, public_key)8.3 Signature Scope
When signing a larger structure that contains a signature field, the signature field MUST be excluded from the object before signing. The convention used throughout the protocol:
Command envelopes: The signature covers { header, payload }. The signature field is added to the envelope after signing and removed before verification.
signable = { header: command.header, payload: command.payload }
signature = sign_json_object(signable, private_key)
command.signature = signatureEvent envelopes: The signature covers { version, contract_id, epoch, event_id, event, timestamp }. See [DCTRL-0005 Section 4].
Public events: The signature covers { event, tags, timestamp, event_id }. See [DCTRL-0007 Section 8].
Both event signatures cover the publisher-chosen event_id, so a mediator cannot change or strip the ID that recipients dedupe on.
9. Multibase Encoding
9.1 Purpose
The Decentrl Protocol uses [Multibase] encoding for public keys in DID strings and DID documents. Multibase prefixes the encoded data with a character that identifies the encoding scheme.
9.2 Supported Encodings
| Encoding | Prefix | Alphabet | Used For |
|---|---|---|---|
| base58btc | z | 123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz | Public keys (signing, pre-key) |
| base64 | m | A-Za-z0-9+/= | Alias, mediator DID in did:decentrl |
9.3 Encoding
function multibase_encode(data_bytes, encoding):
if encoding == "base58btc":
return "z" + base58btc_encode(data_bytes)
if encoding == "base64":
return "m" + base64_encode(data_bytes)9.4 Decoding
function multibase_decode(encoded_string):
prefix = encoded_string[0]
payload = encoded_string[1..]
if prefix == "z":
return base58btc_decode(payload)
if prefix == "m":
return base64_decode(payload)
error("unsupported multibase encoding")9.5 Base58btc Alphabet
The base58btc encoding uses the Bitcoin alphabet, which excludes visually ambiguous characters (0, O, I, l):
123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyzLeading zero bytes in the input are encoded as leading 1 characters.
10. Base64 Encoding
10.1 Standard Base64
The Decentrl Protocol uses standard base64 encoding as specified in [RFC 4648 Section 4] for:
- Signature strings in JSON messages
- AES-GCM ciphertext blobs
- Encrypted tag values
- Ephemeral public keys in communication contracts
The alphabet is A-Za-z0-9+/ with = padding.
10.2 Operations
function base64_encode(bytes):
return standard_base64_encode(bytes) // with padding
function base64_decode(string):
return standard_base64_decode(string)11. Encrypted Tags
11.1 Purpose
Encrypted tags enable mediators to index and query events without knowing the tag semantics. Tags are deterministic per owner: the same identity and tag string always produce the same encrypted tag, enabling exact-match lookups.
11.2 Generation
Input: A tag string (UTF-8) and the identity's 32-byte storage key.
Output: A base64-encoded encrypted tag string.
A tag key MUST first be derived from the storage key using HKDF-SHA256:
function derive_tag_key(storage_key):
return HKDF_SHA256(
ikm = storage_key,
salt = empty,
info = UTF8_encode("decentrl/tag-key/v1"),
length = 32
)
function generate_encrypted_tag(tag_string, tag_key):
tag_bytes = UTF8_encode(tag_string)
mac = HMAC_SHA256(tag_key, tag_bytes) // 32 bytes
return base64_encode(mac)The "encrypted tag" is technically an HMAC of the tag string under a key only the owner holds. This provides the required properties:
- Deterministic: Same owner + same tag → same output, so the mediator can match equal tags in queries
- Opaque: The mediator cannot derive the plaintext tag from the encrypted tag
- Not testable: Without the tag key, nobody (including the mediator) can compute the encrypted tag for a guessed tag string and compare it against stored values
- Owner-only: Only the identity holding the storage key can compute its encrypted tags
Earlier versions of this specification produced encrypted tags as Ed25519 signatures of the tag string. Because a signature can be verified with the public signing key published in the DID document, a mediator could test guesses (e.g., "chat", "participant.did:decentrl:...") against stored tags. The HMAC construction removes this: verifying a guess requires the secret tag key.
Encrypted tags still leak equality. A mediator learns that two events carry the same tag, although not what the tag is. Implementations SHOULD keep this in mind when choosing tag granularity.
11.3 Querying
To query events by tag, the client generates the encrypted tag for the desired tag string using the same operation and sends it to the mediator. The mediator performs an exact-match lookup on the stored encrypted tag values.
query_tag = generate_encrypted_tag("chat.abc123", derive_tag_key(storage_key))
// Send query_tag to mediator in QUERY_EVENTS filter11.4 Tag Strings
Tag strings are arbitrary UTF-8 strings. The Decentrl Protocol does not impose constraints on tag string content or length, but applications typically use dot-separated hierarchical names:
"chat"
"chat.abc123"
"participant.did:decentrl:..."
"readmarker.abc123"Tag template interpolation (e.g., "chat.${chatId}") is an application-level concern defined in application schemas, not in this specification.
11.5 Dedupe Keys
The same construction produces the dedupe key of a stored event [DCTRL-0005 Section 9.5]:
dedupe_key = generate_encrypted_tag("event|" + sender_did + "|" + event_id, derive_tag_key(storage_key))It has the properties of an encrypted tag: the owner's mediator can match equal keys but cannot learn or test the event ID behind them. Dedupe keys are sent in their own dedupe_key field, not among the event's tags.
12. Contract Key Epochs
12.1 Purpose
Events between two contract parties are encrypted under a key that changes once per epoch (one day, [DCTRL-0003 Section 10]). The keys form a one-way hash chain: from the key of one epoch, the next can be computed, but no earlier one. Once both parties have moved past an epoch and deleted its key, nothing they still hold can decrypt ciphertext from that epoch.
12.2 Derivation
All three steps use HKDF-SHA256 [RFC 5869] with a 32-byte output. info strings and the salt are UTF-8 encoded:
// Once per contract, right after the X25519 agreement of the two ephemeral keys
chain_0 = HKDF_SHA256(
ikm = root_secret,
salt = UTF8_encode(contract_id), // the base64 string of [DCTRL-0003 Section 7]
info = UTF8_encode("decentrl/epoch-chain/v1"),
length = 32
)
wipe(root_secret)
// One step forward along the chain
chain_{n+1} = HKDF_SHA256(
ikm = chain_n,
salt = empty,
info = UTF8_encode("decentrl/epoch-chain-next/v1"),
length = 32
)
// AES-256-GCM key for epoch n
epoch_key_n = HKDF_SHA256(
ikm = chain_n,
salt = empty,
info = UTF8_encode("decentrl/epoch-key/v1"),
length = 32
)Salting chain_0 with the contract ID keeps the chains of two contracts apart even if their root secrets were equal. The version suffix (/v1) on each info string lets the whole scheme be replaced at once.
12.3 Requirements
- The root secret MUST be used only to derive
chain_0, and MUST be wiped immediately afterwards. It MUST NOT be stored or used as an encryption key. - Implementations store only the current chain key and the epoch keys of a bounded number of earlier epochs still needed for events in flight [DCTRL-0005 Section 9]. A chain key or epoch key that has been deleted cannot be recomputed from anything kept.
- The epoch key, not the chain key, is used for AES-256-GCM [Section 6]. A chain key MUST NOT be used as an encryption key.
13. Security Considerations
13.1 Random Number Generation
All random byte generation (key generation, AES-GCM nonces) MUST use a cryptographically secure random number generator. Use of non-cryptographic random sources (e.g., Math.random()) is a critical vulnerability.
13.2 Timing Attacks
All cryptographic comparison operations (signature verification, tag comparison) MUST use constant-time algorithms to prevent timing side-channel attacks.
13.3 Key Material Handling
Private keys, root secrets, and the storage key MUST be zeroed from memory after use when possible. Implementations SHOULD use secure memory allocation that prevents key material from being swapped to disk.
13.4 AES-GCM Nonce Reuse
Reusing a nonce with the same AES-GCM key catastrophically breaks both confidentiality and authenticity. The protocol mitigates this through:
- Fresh random nonces for every encryption operation
- Daily key epochs that replace a contract's encryption key [Section 12]
- The birthday bound of 2^48 for 96-bit nonces provides ample margin for one epoch's traffic
13.5 Ed25519 Determinism
Ed25519 signatures are deterministic (same message + same key = same signature): signing the same message twice produces identical signatures. This does not pose a security risk in the protocol's usage. Encrypted tags do not rely on this property; their determinism comes from HMAC with the owner's tag key [Section 11.2].
13.6 Canonical JSON Interoperability
The canonical JSON algorithm depends on consistent key sorting across implementations. Implementations MUST use Unicode code point ordering for key sorting. Any deviation in sort order will produce different byte sequences and cause signature verification failures.
14. References
14.1 Normative References
- [RFC 2119] Bradner, S., "Key words for use in RFCs to Indicate Requirement Levels", BCP 14, RFC 2119, March 1997.
- [RFC 4648] Josefsson, S., "The Base16, Base32, and Base64 Data Encodings", RFC 4648, October 2006.
- [RFC 7748] Langley, A., Hamburg, M., and S. Turner, "Elliptic Curves for Security", RFC 7748, January 2016.
- [RFC 2104] Krawczyk, H., Bellare, M., and R. Canetti, "HMAC: Keyed-Hashing for Message Authentication", RFC 2104, February 1997.
- [RFC 5869] Krawczyk, H. and P. Eronen, "HMAC-based Extract-and-Expand Key Derivation Function (HKDF)", RFC 5869, May 2010.
- [RFC 8032] Josefsson, S. and I. Liusvaara, "Edwards-Curve Digital Signature Algorithm (EdDSA)", RFC 8032, January 2017.
- [NIST-SP-800-38D] Dworkin, M., "Recommendation for Block Cipher Modes of Operation: Galois/Counter Mode (GCM) and GMAC", NIST SP 800-38D, November 2007.
- [MULTIBASE] Multiformats, "Multibase: Self-identifying base encodings". https://github.com/multiformats/multibase
14.2 Informative References
- [BASE58BTC] Bitcoin Wiki, "Base58Check encoding".
- [DCTRL-0001] "The
did:decentrlDID Method". - [DCTRL-0003] "Communication Contracts".
- [DCTRL-0005] "Event Encryption and Storage".
DCTRL-0001: The `did:decentrl` DID Method
Self-contained DID method that embeds public keys and mediator endpoint directly in the identifier string.
DCTRL-0003: Communication Contracts
Bilateral cryptographic agreements with ephemeral key exchange, mutual signatures, daily key epochs, and unilateral revocation.