first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,104 @@
# crypto-sync
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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
```ts
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
```ts
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.