Olympus Docs
InternalsSDK

SDK, encryption format

On-disk format for encrypted settings, including the v2 prefix

Encrypted settings written by the SDK have a deterministic format. Knowing it helps with: forensics, format migration, debugging settings-vault corruption.

Current format: v2

v2:<base64-url(nonce)>:<base64-url(ciphertext + auth_tag)>

Where:

  • nonce, 12 random bytes per encryption (AES-GCM standard).
  • ciphertext, AES-256-GCM ciphertext.
  • auth_tag, 16-byte authentication tag, appended to ciphertext by GCM mode.

Total overhead per encrypted value: ~50 bytes (prefix + nonce + auth tag + base64 inflation).

Key derivation

The encryption key is not ENCRYPTION_KEY directly. It's derived per-record via HKDF-SHA256:

record_key = HKDF-SHA256(
  ikm   = base64-decode(ENCRYPTION_KEY),     // 32 bytes
  salt  = nonce[0:16],                       // first 16 bytes of nonce; wait nonce is 12 bytes...
  info  = utf8("olympus.settings.v2:" || record_id),
  length = 32
)

(Implementation note: the salt is actually a fixed scheme per-record; the salt-mixing details are in crypto.ts. The point is per-record derived keys, not master-key reuse.)

record_id is the database row's primary key (UUID). Same plaintext encrypted in different rows produces different ciphertexts.

AAD (additional authenticated data)

GCM mode supports AAD, data that's authenticated but not encrypted. The SDK uses:

aad = utf8(record_id || ":" || record_version)

Tampering with the row's metadata (e.g. swapping ciphertext between rows) invalidates the AAD and the GCM tag check fails on decrypt.

Format version evolution

v2: is the current prefix. The format may evolve:

  • v1: legacy format used in early Olympus development. The SDK still decodes v1 for backwards-compat but writes only v2. To remove v1 support entirely, run the migration in Operate, Encryption key rotation which upgrades all v1 rows to v2.

  • v3+: hypothetical future. The prefix is forward-looking, if AES-256-GCM is ever supplanted, a new format version would coexist with v2 during a migration window.

Decoding a value manually

For forensics or debugging:

const enc = "v2:Z3VHZWE...:AbCd...";
const [version, nonceB64, ciphertextB64] = enc.split(":");
const nonce = Buffer.from(nonceB64, "base64url");
const ciphertext = Buffer.from(ciphertextB64, "base64url");

// To decrypt, you need:
// - ENCRYPTION_KEY (current production value)
// - record_id (the DB row's id)
// - knowledge of the HKDF info string

You can also use the SDK's decrypt():

import { decrypt } from "@olympusoss/sdk";
const plaintext = decrypt(enc, record_id);

What if the key is wrong

Decryption with the wrong key produces a GCM tag verification failure, the SDK throws OperationError: Decryption failed. The plaintext is not silently corrupted; you get a hard failure.

If you see this error in production logs, it means:

  • ENCRYPTION_KEY was rotated without proper migration.
  • A ciphertext from one deployment was copied into another with a different key.
  • Database corruption.

What's NOT encrypted

  • The settings key (e.g. social.google.client_id).
  • The settings metadata (created_at, modified_at).
  • Anything outside the settings vault, Kratos/Hydra encrypt their own sensitive data with their own keys.

Only the value column of settings rows is encrypted by the SDK.

What's NEVER stored

  • The encryption key itself.
  • Decrypted plaintexts (the cache holds them transiently in-process; never written to disk).

On this page