Skip to content

Cryptographic Protocol Specification

VaultSandbox Cryptographic Protocol Specification

Section titled “VaultSandbox Cryptographic Protocol Specification”

Version: 1.0 Status: Draft Date: 2026-01-04

  1. Overview
  2. Notation and Conventions
  3. Cryptographic Algorithms
  4. Key Management
  5. Encrypted Payload Format
  6. Key Derivation
  7. Encryption Process
  8. Decryption Process
  9. Inbox Export Format
  10. Inbox Import Process
  11. Security Considerations
  12. Test Vectors

VaultSandbox implements a hybrid post-quantum encryption protocol for secure email handling. The protocol uses NIST-standardized post-quantum algorithms combined with authenticated encryption to provide confidentiality, integrity, and authenticity of messages.

  • Post-quantum security: Resistant to attacks by quantum computers
  • Forward secrecy: Each message uses fresh key encapsulation
  • Authenticity: Server signatures prevent tampering
  • Interoperability: Consistent behavior across implementations
┌────────┐ ┌────────┐
│ Client │ │ Server │
└───┬────┘ └───┬────┘
│ │
│ 1. Generate ML-KEM keypair │
│ 2. Send public key ──────────│
│ │
│ │ 3. Receive email
│ │ 4. Encapsulate with client's pk
│ │ 5. Derive AES key (HKDF)
│ │ 6. Encrypt email (AES-GCM)
│ │ 7. Sign payload (ML-DSA)
│ │
│ ◄──────── Encrypted email ───│
│ │
│ 8. Verify signature │
│ 9. Decapsulate shared secret │
│ 10. Derive AES key (HKDF) │
│ 11. Decrypt email (AES-GCM) │
│ │

TermDefinition
||Byte concatenation
len(x)Length of x in bytes
BE32(n)4-byte big-endian encoding of unsigned integer n
UTF8(s)UTF-8 encoding of string s

All binary data in JSON fields MUST be encoded as Base64URL without padding (RFC 4648 Section 5).

ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_
  1. Use - instead of +
  2. Use _ instead of /
  3. Do NOT include = padding characters
  4. Implementations MUST reject input containing +, /, or =
Raw bytes (hex)Standard Base64Base64URL (correct)
0xfb+w==-w
0x3ePg==Pg
0xabcdefq83vq83v

All timestamps MUST be formatted as ISO 8601 strings in UTC with the Z suffix:

YYYY-MM-DDTHH:mm:ss.sssZ

Milliseconds are optional. Examples:

  • 2024-01-15T10:30:00Z
  • 2024-01-15T10:30:00.000Z

The protocol uses the following algorithm suite, identified by the string:

ML-KEM-768:ML-DSA-65:AES-256-GCM:HKDF-SHA-512

All implementations MUST support this suite. Implementations MUST reject payloads specifying different algorithms.

ParameterValue
StandardNIST FIPS 203
Security level192-bit (classical and quantum)
Public key size1184 bytes
Secret key size2400 bytes
Ciphertext size1088 bytes
Shared secret size32 bytes

The ML-KEM-768 secret key embeds the public key:

Secret Key (2400 bytes):
┌───────────────────────────────────────────────────────────────────────────────┐
│ Private material (1152 bytes) │ Public key (1184 bytes) │ h (32) │ z (32) │
│ [0:1152] │ [1152:2336] │ │ │
└───────────────────────────────────────────────────────────────────────────────┘
Public Key Offset: 1152
Public Key Length: 1184 bytes

Implementations MUST derive the public key from the secret key at offset 1152 (bytes 1152-2335, 1184 bytes) rather than storing it separately.

ParameterValue
StandardNIST FIPS 204
Security level192-bit
Public key size1952 bytes
Signature size3309 bytes

3.4 AES-256-GCM (Authenticated Encryption)

Section titled “3.4 AES-256-GCM (Authenticated Encryption)”
ParameterValue
Key size256 bits (32 bytes)
Nonce size96 bits (12 bytes)
Tag size128 bits (16 bytes)

The authentication tag is appended to the ciphertext.

ParameterValue
Hash functionSHA-512
Output key length256 bits (32 bytes)

Clients MUST generate a fresh ML-KEM-768 keypair for each inbox. The keypair consists of:

  • Secret key: 2400 bytes, kept confidential by the client
  • Public key: 1184 bytes, sent to the server during inbox creation

Given a secret key sk of 2400 bytes, the public key is extracted as:

pk = sk[1152:2336]

Where sk[a:b] denotes bytes from index a (inclusive) to index b (exclusive). This extracts exactly 1184 bytes.

The server’s ML-DSA-65 public key is provided during inbox creation and MUST be stored alongside the inbox. This key is used to verify all encrypted payloads.

Implementations MUST verify that the server_sig_pk in each payload matches the pinned server key from inbox creation.


{
"v": 1,
"algs": {
"kem": "ML-KEM-768",
"sig": "ML-DSA-65",
"aead": "AES-256-GCM",
"kdf": "HKDF-SHA-512"
},
"ct_kem": "<base64url>",
"nonce": "<base64url>",
"aad": "<base64url>",
"ciphertext": "<base64url>",
"sig": "<base64url>",
"server_sig_pk": "<base64url>"
}
FieldTypeDescription
vintegerProtocol version. MUST be 1.
algsobjectAlgorithm identifiers.
algs.kemstringKEM algorithm. MUST be "ML-KEM-768".
algs.sigstringSignature algorithm. MUST be "ML-DSA-65".
algs.aeadstringAEAD algorithm. MUST be "AES-256-GCM".
algs.kdfstringKDF algorithm. MUST be "HKDF-SHA-512".
ct_kemstringML-KEM ciphertext (1088 bytes decoded).
noncestringAES-GCM nonce (12 bytes decoded).
aadstringAdditional authenticated data.
ciphertextstringAES-GCM ciphertext with appended tag.
sigstringML-DSA signature over transcript (3309 bytes decoded).
server_sig_pkstringServer’s ML-DSA public key (1952 bytes decoded).

Implementations MUST validate decoded sizes:

FieldExpected size
ct_kem1088 bytes
nonce12 bytes
sig3309 bytes
server_sig_pk1952 bytes

The AES-256 key is derived using HKDF-SHA-512 with the following parameters:

ParameterValue
IKM (Input Key Material)ML-KEM shared secret (32 bytes)
SaltSHA-256(ct_kem)
InfoSee Section 6.2
Output length32 bytes

The HKDF info parameter provides domain separation and binds the key to the AAD:

info = context || BE32(len(aad)) || aad

Where:

  • context = UTF8(“vaultsandbox:email:v1”) (22 bytes)
  • BE32(len(aad)) = 4-byte big-endian length of AAD
  • aad = raw AAD bytes

The context string MUST be exactly:

vaultsandbox:email:v1

This string provides domain separation to prevent key reuse across different applications or protocol versions.

function deriveKey(sharedSecret, aad, ctKem):
salt = SHA256(ctKem)
context = UTF8("vaultsandbox:email:v1")
aadLength = BE32(len(aad))
info = context || aadLength || aad
return HKDF-SHA512(
ikm = sharedSecret,
salt = salt,
info = info,
length = 32
)

This section describes the server-side encryption process for reference.

  1. Generate nonce: Generate 12 cryptographically random bytes for the AES-GCM nonce.

  2. Encapsulate: Using the client’s ML-KEM public key:

    (ctKem, sharedSecret) = ML-KEM-768.Encapsulate(clientPublicKey)
  3. Derive AES key:

    aesKey = deriveKey(sharedSecret, aad, ctKem)
  4. Encrypt:

    ciphertext = AES-256-GCM.Encrypt(aesKey, nonce, plaintext, aad)

    The ciphertext includes the 16-byte authentication tag appended.

  5. Build transcript: See Section 7.2.

  6. Sign:

    signature = ML-DSA-65.Sign(serverSecretKey, transcript)
  7. Assemble payload: Construct the JSON payload per Section 5.

The signature is computed over a transcript that binds all payload components:

transcript = version || algs || context || ctKem || nonce || aad || ciphertext || serverSigPk

Where:

  • version = single byte with value 0x01
  • algs = UTF8(“ML-KEM-768:ML-DSA-65:AES-256-GCM:HKDF-SHA-512”)
  • context = UTF8(“vaultsandbox:email:v1”)
  • ctKem = raw KEM ciphertext (1088 bytes)
  • nonce = raw nonce (12 bytes)
  • aad = raw AAD bytes
  • ciphertext = raw ciphertext with tag
  • serverSigPk = raw server public key (1952 bytes)

Implementations MUST perform these steps in order:

  1. Parse payload: Decode JSON and validate structure per Section 5.

  2. Validate version: Verify v == 1. Reject otherwise.

  3. Validate algorithms: Verify all algorithm fields match expected values. Reject otherwise.

  4. Validate sizes: Verify decoded binary fields have correct sizes per Section 5.3.

  5. Verify server key: Compare server_sig_pk against the pinned server key from inbox creation.

    if server_sig_pk != pinnedServerKey:
    return ERROR_SERVER_KEY_MISMATCH
  6. Verify signature (BEFORE decryption):

    transcript = buildTranscript(payload)
    if not ML-DSA-65.Verify(server_sig_pk, transcript, sig):
    return ERROR_SIGNATURE_INVALID
  7. Decapsulate:

    sharedSecret = ML-KEM-768.Decapsulate(clientSecretKey, ctKem)
  8. Derive AES key:

    aesKey = deriveKey(sharedSecret, aad, ctKem)
  9. Decrypt:

    plaintext = AES-256-GCM.Decrypt(aesKey, nonce, ciphertext, aad)
    if decryption fails:
    return ERROR_DECRYPTION_FAILED
  • Signature verification MUST occur before decryption to prevent chosen-ciphertext attacks.
  • Implementations MUST NOT return different errors for signature failure vs. decryption failure to prevent oracle attacks. Use a generic “decryption failed” error.
  • Implementations MUST use constant-time comparison for server key verification.

The export format allows users to back up inbox credentials and restore them in any compatible client implementation.

{
"version": 1,
"emailAddress": "example@vaultsandbox.com",
"expiresAt": "2024-01-20T15:30:00Z",
"inboxHash": "abc123...",
"serverSigPk": "<base64url>",
"secretKey": "<base64url>",
"exportedAt": "2024-01-13T10:00:00Z"
}
FieldTypeRequiredDescription
versionintegerYesExport format version. MUST be 1.
emailAddressstringYesThe inbox email address. MUST contain @.
expiresAtstringYesInbox expiration timestamp (ISO 8601).
inboxHashstringYesUnique inbox identifier. Non-empty.
serverSigPkstringYesServer’s ML-DSA-65 public key (base64url).
secretKeystringYesML-KEM-768 secret key (base64url, 2400 bytes decoded).
exportedAtstringYesExport timestamp (ISO 8601).

Integer value indicating the export format version. This specification defines version 1.

Implementations MUST reject exports with unknown versions.

The email address assigned to the inbox. MUST contain exactly one @ character.

ISO 8601 timestamp indicating when the inbox expires and will no longer receive emails.

A unique identifier for the inbox, typically derived from the public key. Used for API operations.

The server’s ML-DSA-65 public key, encoded as base64url without padding.

Decoded size MUST be exactly 1952 bytes.

The client’s ML-KEM-768 secret key, encoded as base64url without padding.

Decoded size MUST be exactly 2400 bytes.

The public key is NOT included in the export as it can be derived from the secret key (see Section 4.2).

ISO 8601 timestamp indicating when the export was created. Informational only.

{
"version": 1,
"emailAddress": "temp_abc123@vaultsandbox.com",
"expiresAt": "2024-01-20T15:30:00.000Z",
"inboxHash": "7f83b1657ff1fc53b92dc18148a1d65dfc2d4b1fa3d677284addd200126d9069",
"serverSigPk": "MIIFIjANBgkqhk...<truncated>",
"secretKey": "MIIEvgIBADANBg...<truncated>",
"exportedAt": "2024-01-13T10:00:00.000Z"
}

Exported files SHOULD use the following naming pattern:

inbox-{sanitized_email}.json

Where sanitized_email is the email address with:

  • @ replaced with _at_
  • Any character not in [a-zA-Z0-9._-] replaced with _

Example: inbox-temp_abc123_at_vaultsandbox_com.json

  • Exported files contain private key material and MUST be handled securely.
  • Implementations SHOULD set restrictive file permissions (e.g., 0600 on Unix systems).
  • Implementations SHOULD warn users that the export contains sensitive data.
  • Implementations MAY offer password-protected exports in future versions.

Implementations MUST validate imported data in the following order:

  1. Parse JSON: Verify the input is valid JSON.

  2. Validate version:

    if version != 1:
    return ERROR_UNSUPPORTED_VERSION
  3. Validate required fields: All fields from Section 9.3 MUST be present and non-null.

  4. Validate emailAddress:

    • MUST be a non-empty string
    • MUST contain exactly one @ character
    if emailAddress == "" or count(emailAddress, "@") != 1:
    return ERROR_INVALID_EMAIL
  5. Validate inboxHash:

    • MUST be a non-empty string
    if inboxHash == "":
    return ERROR_INVALID_INBOX_HASH
  6. Validate and decode secretKey:

    secretKeyBytes = base64url_decode(secretKey)
    if decoding fails:
    return ERROR_INVALID_SECRET_KEY
    if len(secretKeyBytes) != 2400:
    return ERROR_INVALID_SECRET_KEY_SIZE
  7. Validate and decode serverSigPk:

    serverSigPkBytes = base64url_decode(serverSigPk)
    if decoding fails:
    return ERROR_INVALID_SERVER_KEY
    if len(serverSigPkBytes) != 1952:
    return ERROR_INVALID_SERVER_KEY_SIZE
  8. Validate timestamps:

    • expiresAt MUST be a valid ISO 8601 timestamp
    • exportedAt MUST be a valid ISO 8601 timestamp

After validation, reconstruct the full keypair:

secretKey = base64url_decode(export.secretKey)
publicKey = secretKey[1152:2336]
keypair = { secretKey, publicKey }

Create a new inbox instance with:

  • Email address from export
  • Expiration time from export
  • Inbox hash from export
  • Server signature public key from export
  • Reconstructed keypair

Implementations SHOULD check for existing inboxes with the same email address or inbox hash before import and either:

  • Reject the import with an error, or
  • Prompt the user to confirm replacement

All random values (keypairs, nonces) MUST be generated using a cryptographically secure random number generator (CSPRNG).

Implementations SHOULD:

  • Zero secret key material after use when possible
  • Avoid logging or serializing secret keys except for export
  • Use secure memory allocations where available

Implementations MUST use constant-time operations for:

  • Server key comparison
  • Signature verification
  • Any comparison involving secret data

Implementations MUST NOT reveal whether a failure occurred during:

  • Signature verification vs. decryption
  • MAC verification vs. decryption

Use generic error messages to prevent oracle attacks.

  • Secret keys MUST be stored securely (encrypted storage, secure enclave, etc.)
  • Exported files SHOULD have restrictive permissions
  • Applications SHOULD implement secure deletion of exports after import

Input (hex)Output (base64url)
(empty)(empty)
00AA
fb-w
fbff-_8
000102030405060708090a0b0c0d0e0fAAECAwQFBgcICQoLDA0ODw

Given:

  • Context: "vaultsandbox:email:v1" (22 bytes)
  • AAD: 0x010203 (3 bytes)

Info parameter (hex):

7661756c7473616e64626f783a656d61696c3a7631 (context: "vaultsandbox:email:v1")
00000003 (BE32 length: 3)
010203 (AAD bytes)

Full info (29 bytes, hex):

7661756c7473616e64626f783a656d61696c3a763100000003010203

Given:

  • version: 1
  • algs: "ML-KEM-768:ML-DSA-65:AES-256-GCM:HKDF-SHA-512"
  • context: "vaultsandbox:email:v1"
  • ctKem: (1088 bytes, represented as <ct_kem>)
  • nonce: (12 bytes, represented as <nonce>)
  • aad: (variable, represented as <aad>)
  • ciphertext: (variable, represented as <ciphertext>)
  • serverSigPk: (1952 bytes, represented as <server_pk>)

Transcript:

01 (version byte)
4d4c2d4b454d2d3736383a4d4c2d4453412d36353a (algs string start)
4145532d3235362d47434d3a484b44462d5348412d353132 (algs string end)
7661756c7473616e64626f783a656d61696c3a7631 (context)
<ct_kem> (1088 bytes)
<nonce> (12 bytes)
<aad> (variable)
<ciphertext> (variable)
<server_pk> (1952 bytes)

IdentifierAlgorithmStandard
ML-KEM-768Module-Lattice Key Encapsulation MechanismNIST FIPS 203
ML-DSA-65Module-Lattice Digital Signature AlgorithmNIST FIPS 204
AES-256-GCMAdvanced Encryption Standard in Galois/Counter ModeNIST SP 800-38D
HKDF-SHA-512HMAC-based Key Derivation Function with SHA-512RFC 5869
ConstantValueDescription
MLKEM_PUBLIC_KEY_SIZE1184ML-KEM-768 public key size in bytes
MLKEM_SECRET_KEY_SIZE2400ML-KEM-768 secret key size in bytes
MLKEM_CIPHERTEXT_SIZE1088ML-KEM-768 ciphertext size in bytes
MLKEM_SHARED_SECRET_SIZE32ML-KEM-768 shared secret size in bytes
MLKEM_PUBLIC_KEY_OFFSET1152Offset of public key within secret key
MLDSA_PUBLIC_KEY_SIZE1952ML-DSA-65 public key size in bytes
MLDSA_SIGNATURE_SIZE3309ML-DSA-65 signature size in bytes
AES_KEY_SIZE32AES-256 key size in bytes
AES_NONCE_SIZE12AES-GCM nonce size in bytes
AES_TAG_SIZE16AES-GCM authentication tag size in bytes

Implementations SHOULD use consistent error identification:

ErrorDescription
UNSUPPORTED_VERSIONProtocol or export version not supported
INVALID_PAYLOADMalformed JSON or missing required fields
INVALID_ALGORITHMUnrecognized or unsupported algorithm
INVALID_SIZEDecoded field has incorrect size
SERVER_KEY_MISMATCHServer public key doesn’t match pinned key
SIGNATURE_INVALIDSignature verification failed
DECRYPTION_FAILEDAEAD decryption or authentication failed
INVALID_IMPORT_DATAExport file validation failed

VersionDateChanges
1.02024-01-03Initial specification