Files
skills/docs/security-stack/security/vault-sync/src/crypto-sync/crypto-sync.md
2026-05-29 08:20:56 +01:00

4.4 KiB

crypto-sync

(no submodules)

Purpose

crypto-sync.ts provides the AEAD-encrypted envelope used by SyncEngine and SyncQueue to put plaintext sync payloads on the wire. Encryption is XChaCha20-Poly1305 from @suaveplan/crypto-cipher with a 24-byte nonce and an AAD-bound version string ("v1"). The version binding means a future revision can change the wire layout without risk that a tampered v2 payload is silently decoded as v1. The module enforces a 10 MiB plaintext cap to prevent memory abuse from oversized snapshots.

Features

  • encryptSyncPayload(plaintext, key, options?) — XChaCha20-Poly1305 AEAD
  • decryptSyncPayload(payload, key, options?) — verified decrypt with AAD
  • AAD = "v1" || optional caller-supplied bytes
  • Random 24-byte nonce sourced via @suaveplan/crypto-utils.randomBytes
  • Injectable byteSource for deterministic test nonces
  • Hard cap of 10 MiB on plaintext (SYNC.PAYLOAD_TOO_LARGE)
  • Structured errors: SYNC.PAYLOAD_TAMPERED, SYNC.PAYLOAD_INVALID_VERSION
  • OTel histogram + outcome counter (no key bytes, no plaintext, no IDs)

Usage: Basic Example

import { decryptSyncPayload, encryptSyncPayload } from "@suaveplan/vault-sync";

const key = new Uint8Array(32); // 32 bytes — ChaCha20 key
crypto.getRandomValues(key);
const plaintext = new TextEncoder().encode(JSON.stringify({ items: [] }));

const envelope = encryptSyncPayload(plaintext, key);
const recovered = decryptSyncPayload(envelope, key);

console.log(new TextDecoder().decode(recovered));

Usage: Advanced Example

import { encryptSyncPayload } from "@suaveplan/vault-sync";
import { decryptSyncPayload } from "@suaveplan/vault-sync";
import { VaultSyncError } from "@suaveplan/vault-sync";
import { VAULT_SYNC_CODES } from "@suaveplan/vault-sync/errors";
import { zeroize } from "@suaveplan/crypto-utils";

const key = crypto.getRandomValues(new Uint8Array(32));
const plaintext = new TextEncoder().encode("snapshot");
const aad = new TextEncoder().encode("vault-id-binding"); // domain separator

const envelope = encryptSyncPayload(plaintext, key, { additionalData: aad });

try {
    // Caller MUST pass the same additionalData on decrypt — otherwise the
    // tag check fails with SYNC.PAYLOAD_TAMPERED.
    const round = decryptSyncPayload(envelope, key, { additionalData: aad });
    zeroize(round); // clear plaintext when done
} catch (err) {
    if (
        err instanceof VaultSyncError &&
        err.code === VAULT_SYNC_CODES["SYNC.PAYLOAD_TAMPERED"]
    ) {
        // Telemetry already recorded; trigger a fresh full pull.
    }
    throw err;
}

API Reference

  • encryptSyncPayload(plaintext: Uint8Array, key: Uint8Array, options?: EncryptSyncPayloadOptions): EncryptedSyncPayload — encrypts with XChaCha20-Poly1305. Throws VaultSyncError(SYNC.PAYLOAD_TOO_LARGE) if plaintext.length exceeds MAX_SYNC_PAYLOAD_BYTES.
  • decryptSyncPayload(payload: EncryptedSyncPayload, key: Uint8Array, options?): Uint8Array — validates the envelope shape via EncryptedSyncPayloadSchema then verifies and decrypts. Throws VaultSyncError(SYNC.PAYLOAD_INVALID_VERSION) for shape failures and VaultSyncError(SYNC.PAYLOAD_TAMPERED) for tag mismatch (wrong key, flipped byte, mismatched AAD).
  • EncryptSyncPayloadOptions — { byteSource?, additionalData? }.
  • MAX_SYNC_PAYLOAD_BYTES — 10 * 1024 * 1024 (10 MiB).
  • SYNC_PAYLOAD_VERSION — literal "v1".

Implementation Notes

  • The package never generates a nonce internally beyond delegating to randomBytes from @suaveplan/crypto-utils. Callers wanting determinism in tests pass a custom byteSource. There is no notion of nonce reuse detection at this layer — XChaCha20's 192-bit nonce makes random selection collision-safe at any realistic call rate, and higher-level wrapping is left to the consumer if needed.
  • The plaintext returned by decryptSyncPayload is a fresh Uint8Array. The caller is responsible for zeroize-ing it after use; the module cannot do this safely because it does not know when the caller is done reading the value.
  • Both shape failures and tag failures emit payload.outcome.total{status: "tampered"} so a single counter captures the integrity-violation rate.
  • Decryption-side schema validation uses the same EncryptedSyncPayloadSchema exposed publicly, ensuring schema and runtime checks are perfectly aligned.