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:
- the text
oxelone-sealed-v1 - the workspace id, 16 bytes
- the table name
- the row id, 16 bytes
- the batch id, 16 bytes
The plaintext is a JSON object of that table's fields.
| Table | Fields |
|---|---|
observations | prompt_text, request_preview, response_preview |
interaction_attachments | name, preview |
engine_runs | redacted_text, original_excerpt |
interaction_decisions | justification, reference |
artifact_bodies | body |
artifacts | content_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.