decentrl.
Specifications

DCTRL-0002: Cryptographic Operations

Ed25519 signatures, X25519 key agreement, AES-256-GCM encryption, canonical JSON, and encoding formats.

FieldValue
RFCDCTRL-0002
TitleCryptographic Operations
StatusDraft
Created2026-03-14
Version0.1
Requires
Required ByDCTRL-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

  1. Introduction
  2. Terminology
  3. Identity Key Structure
  4. Ed25519 Digital Signatures
  5. X25519 Key Agreement
  6. AES-256-GCM Authenticated Encryption
  7. Canonical JSON Serialization
  8. JSON Object Signing
  9. Multibase Encoding
  10. Base64 Encoding
  11. Encrypted Tags
  12. Security Considerations
  13. 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:

KeyAlgorithmSizePublishedPurpose
Signing keyEd2551932-byte private + 32-byte publicPublic key in DIDAuthentication, signatures
Pre-keyX2551932-byte private + 32-byte publicPublic key in DIDBootstrapping new contracts
Storage keyAES-25632 bytesNeverSelf-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:

  1. Contract request wrapping: X25519(initiator_ephemeral_private, recipient_pre_key_public) — encrypts the contract request so only the recipient can read it.

  2. 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

ParameterSizeSource
Key256 bits (32 bytes)Root secret or storage key
Nonce96 bits (12 bytes)Fresh random per operation
Authentication tag128 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 = signature

Event 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

EncodingPrefixAlphabetUsed For
base58btcz123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyzPublic keys (signing, pre-key)
base64mA-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):

123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz

Leading 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 filter

11.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:decentrl DID Method".
  • [DCTRL-0003] "Communication Contracts".
  • [DCTRL-0005] "Event Encryption and Storage".