Backup encryption

QuietGuard\Monitor\Backup\BackupCipher provides the streaming, hybrid-encryption primitives behind the zero-knowledge backup vault. It is framework-agnostic and built entirely on libsodium (ext-sodium). The server only ever stores the opaque result; it never holds the key, so it can never read a backup.

The scheme

A backup is encrypted with a fresh random symmetric key, and that key is then sealed to the team's X25519 public key:

  • The archive is encrypted with libsodium's secretstream (XChaCha20-Poly1305) in 64 KiB plaintext chunks (chunked AEAD).
  • The random symmetric key is sealed to the team public key with crypto_box_seal.
  • Restoring requires the team private key, which is unwrapped locally from the team passphrase: mirroring the server's wrapping (Argon2id MODERATE → secretbox) so the operator is never in the loop.

The resulting blob layout is:

text
[uint32 sealedKeyLen][sealedKey][24-byte header]([uint32 len][chunk])*

Encrypting a file

php
use QuietGuard\Monitor\Backup\BackupCipher;

$cipher = new BackupCipher();

$cipher->encryptFile(
    inPath: '/tmp/backup.tar',
    outPath: '/tmp/backup.tar.enc',
    publicKeyBase64: $teamPublicKeyBase64,
);

encryptFile() generates a one-time secretstream key, seals it to the base64-encoded public key, writes the sealed key, the stream header and the AEAD chunks (the final chunk carries the TAG_FINAL marker), zeroing the symmetric key from memory when done. It streams the input, so memory use is bounded regardless of archive size.

Decrypting a file

Decryption needs both the public key and the raw private key bytes:

php
$cipher->decryptFile(
    inPath: '/tmp/backup.tar.enc',
    outPath: '/tmp/restored.tar',
    publicKeyBase64: $teamPublicKeyBase64,
    privateKeyRaw: $privateKeyRaw,
);

decryptFile() reads the sealed key, recovers the symmetric key with the X25519 key pair, then pulls and verifies each chunk. It throws a RuntimeException if the key pair cannot open the sealed key ("Unable to recover the backup key (wrong key pair)") or if a chunk fails its authentication tag ("Backup is corrupted or has been tampered with").

Unwrapping the private key

The private key needed above is obtained from the server-wrapped key material plus the team passphrase:

php
$privateKeyRaw = $cipher->unwrapPrivateKey(
    wrappedBase64: $wrappedPrivateKeyBase64,
    saltBase64: $kdfSaltBase64,
    passphrase: $teamPassphrase,
);

unwrapPrivateKey() derives a key-encryption key from the passphrase and salt with Argon2id at the MODERATE ops/mem limits (ALG_ARGON2ID13), then opens the secretbox frame (nonce | ciphertext). It throws a RuntimeException on corrupted material or a wrong passphrase. This intentionally mirrors the server's TeamCipher wrapping so the key can be reconstructed client-side and the server never sees it.

Where this is used

The clients build their monitor:backup / monitor:restore commands on these primitives. The Laravel SDK exposes a thin Support\BackupCipher subclass of this class; the Symfony bundle and WordPress plugin reuse the core directly. The corresponding server-side flow (opaque blob storage, GB quota, key-material API) is part of the Quiet Guard backup vault.

You are reading the PHP Core v1.0 documentation.