DCTRL-0002: Cryptographic 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
- 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 and discarded after the contract is superseded.
Root Secret — A 32-byte shared secret derived via X25519 key agreement between two ephemeral keys. Used as the AES-256-GCM key for a communication contract.
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 |
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
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 the root secret used for event encryption within a communication contract.
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) | Root secret or storage key |
| 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 rotation resets the key (each new root secret is a new key). With reasonable rotation intervals (24 hours or 1,000 events), the per-key encryption count stays well 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 { contract_id, event, timestamp }. See [DCTRL-0005].
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: the same private key and tag string always produce the same encrypted tag, enabling exact-match lookups.
11.2 Generation
Input: A tag string (UTF-8) and a 32-byte Ed25519 private key (the identity's signing key).
Output: A base64-encoded encrypted tag string.
function generate_encrypted_tag(tag_string, signing_private_key):
tag_bytes = UTF8_encode(tag_string)
signature = Ed25519_sign(tag_bytes, signing_private_key) // 64 bytes
return base64_encode(signature)The "encrypted tag" is technically an Ed25519 signature of the tag string. This provides the required properties:
- Deterministic: Same key + same tag → same output (Ed25519 signatures are deterministic)
- Opaque: The mediator cannot derive the plaintext tag from the encrypted tag
- Unforgeable: Only the holder of the signing private key can produce a valid encrypted tag for a given tag string
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", signing_private_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.
12. Security Considerations
12.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.
12.2 Timing Attacks
All cryptographic comparison operations (signature verification, tag comparison) MUST use constant-time algorithms to prevent timing side-channel attacks.
12.3 Key Material Handling
Private keys 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.
12.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
- Contract rotation that periodically replaces the encryption key
- The birthday bound of 2^48 for 96-bit nonces provides ample margin for reasonable contract lifetimes
12.5 Ed25519 Determinism
Ed25519 signatures are deterministic (same message + same key = same signature). This is a feature for encrypted tags (enabling exact-match queries) but means that signing the same message twice produces identical signatures. This does not pose a security risk in the protocol's usage.
12.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.
13. References
13.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 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
13.2 Informative References
- [BASE58BTC] Bitcoin Wiki, "Base58Check encoding".
- [DCTRL-0001] "The
did:decentrlDID Method". - [DCTRL-0003] "Communication Contracts".
- [DCTRL-0005] "Event Encryption and Storage".