Oxelone Docs

Search docs and API

Find a guide, a section of one, or an API operation by its name.

Open console (opens in a new tab)

Security and trust Decrypting outside the console

Decrypting outside the console

With Data encryption on, the API and log streams return content encrypted. You can open it with your private key in your own tooling. Oxelone is not involved.

The format

A content response carries:

{
  "content_state": "encrypted",
  "sealed": {
    "workspace_id": "…",
    "table": "observations",
    "row_id": "…",
    "batch_id": "…",
    "blob": "base64"
  }
}

Every byte field is base64.

Fetch the batch key. GET /encryption/batch-keys?ids=<batch_id> returns wrapped, a standard age file. Decrypted with your private key, it holds one line: the batch id, a space, and the 32-byte batch key in base64.

Open the blob. The blob is one version byte 0x01, a 24-byte nonce, then XChaCha20-Poly1305 ciphertext. The associated data is these parts joined by a single zero byte:

  1. the text oxelone-sealed-v1
  2. the workspace id, 16 bytes
  3. the table name
  4. the row id, 16 bytes
  5. the batch id, 16 bytes

The plaintext is a JSON object of that table's fields.

TableFields
observationsprompt_text, request_preview, response_preview
interaction_attachmentsname, preview
engine_runsredacted_text, original_excerpt
interaction_decisionsjustification, reference
artifact_bodiesbody
artifactscontent_preview

Node.js

npm install age-encryption @noble/ciphers
import { Decrypter } from "age-encryption";
import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";

const b64 = (s) => Uint8Array.from(Buffer.from(s, "base64"));
const uuid = (s) => Uint8Array.from(Buffer.from(s.replaceAll("-", ""), "hex"));
const text = (s) => new TextEncoder().encode(s);

export async function openBatchKey(wrapped, privateKey) {
  const d = new Decrypter();
  d.addIdentity(privateKey);
  const line = await d.decrypt(b64(wrapped), "text");
  return b64(line.trim().split(" ")[1]);
}

export function openSealed(sealed, batchKey) {
  const blob = b64(sealed.blob);
  if (blob[0] !== 1) throw new Error("unknown version");
  const parts = [text("oxelone-sealed-v1"), uuid(sealed.workspace_id), text(sealed.table), uuid(sealed.row_id), uuid(sealed.batch_id)];
  const aad = Buffer.concat(parts.flatMap((p, i) => (i ? [Buffer.from([0]), p] : [p])));
  const plain = xchacha20poly1305(batchKey, blob.subarray(1, 25), aad).decrypt(blob.subarray(25));
  return JSON.parse(new TextDecoder().decode(plain));
}

Python

pip install pyrage pynacl
import base64, json, uuid
import pyrage
from nacl.bindings import crypto_aead_xchacha20poly1305_ietf_decrypt as open_aead

def open_batch_key(wrapped: str, private_key: str) -> bytes:
    identity = pyrage.x25519.Identity.from_str(private_key)
    line = pyrage.decrypt(base64.b64decode(wrapped), [identity]).decode()
    return base64.b64decode(line.split()[1])

def open_sealed(sealed: dict, batch_key: bytes) -> dict:
    blob = base64.b64decode(sealed["blob"])
    if blob[0] != 1:
        raise ValueError("unknown version")
    aad = b"\0".join([
        b"oxelone-sealed-v1",
        uuid.UUID(sealed["workspace_id"]).bytes,
        sealed["table"].encode(),
        uuid.UUID(sealed["row_id"]).bytes,
        uuid.UUID(sealed["batch_id"]).bytes,
    ])
    plain = open_aead(blob[25:], aad, blob[1:25], batch_key)
    return json.loads(plain)

Log streams

With Include content on and encryption on, an event carries content with the same fields as sealed, plus batch_key_age, the batch key's age file, so your SIEM pipeline needs no call back to Oxelone. Decrypt it before storing the event, or store it encrypted and decrypt on read.

Cache opened batch keys for the length of a run. Many records share one.