Phase 0: skeleton, streaming encryption (spike A)
Repository skeleton (app id ng_backup, AGPL-3.0-or-later), KeyRing with KDF subkeys, keyed blob ids and Argon2id key wrapping, and StreamCipher (secretstream XChaCha20-Poly1305, framed, bounded memory). Spike A: 1 GiB round trip with memory_limit=64M, peak < 2 MiB, ~160/122 MiB/s; tampering, truncation, wrong key/passphrase are rejected.
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\AppInfo;
|
||||
|
||||
use OCP\AppFramework\App;
|
||||
use OCP\AppFramework\Bootstrap\IBootContext;
|
||||
use OCP\AppFramework\Bootstrap\IBootstrap;
|
||||
use OCP\AppFramework\Bootstrap\IRegistrationContext;
|
||||
|
||||
class Application extends App implements IBootstrap {
|
||||
public const APP_ID = 'ng_backup';
|
||||
|
||||
public function __construct() {
|
||||
parent::__construct(self::APP_ID);
|
||||
}
|
||||
|
||||
public function register(IRegistrationContext $context): void {
|
||||
}
|
||||
|
||||
public function boot(IBootContext $context): void {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Crypto;
|
||||
|
||||
class CryptoException extends \RuntimeException {
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Crypto;
|
||||
|
||||
/**
|
||||
* Holds the repository master key and derives the subkeys used for encryption and for the
|
||||
* keyed blob hashes. The master key never leaves this object unencrypted, except through
|
||||
* wrap(), which seals it with a key derived from the admin's passphrase.
|
||||
*/
|
||||
final class KeyRing {
|
||||
private const KDF_CONTEXT = 'ngbackup';
|
||||
private const SUBKEY_DATA = 1;
|
||||
private const SUBKEY_HASH = 2;
|
||||
|
||||
private string $dataKey;
|
||||
private string $hashKey;
|
||||
|
||||
public function __construct(
|
||||
#[\SensitiveParameter]
|
||||
private string $masterKey,
|
||||
) {
|
||||
if (strlen($masterKey) !== SODIUM_CRYPTO_KDF_KEYBYTES) {
|
||||
throw new \InvalidArgumentException('Master key must be ' . SODIUM_CRYPTO_KDF_KEYBYTES . ' bytes');
|
||||
}
|
||||
$this->dataKey = sodium_crypto_kdf_derive_from_key(
|
||||
SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_KEYBYTES, self::SUBKEY_DATA, self::KDF_CONTEXT, $masterKey);
|
||||
$this->hashKey = sodium_crypto_kdf_derive_from_key(
|
||||
SODIUM_CRYPTO_GENERICHASH_KEYBYTES, self::SUBKEY_HASH, self::KDF_CONTEXT, $masterKey);
|
||||
}
|
||||
|
||||
public static function generate(): self {
|
||||
return new self(sodium_crypto_kdf_keygen());
|
||||
}
|
||||
|
||||
public function dataKey(): string {
|
||||
return $this->dataKey;
|
||||
}
|
||||
|
||||
/** Keyed BLAKE2b of plaintext, so blob ids reveal nothing about the content. */
|
||||
public function blobId(string $plaintext): string {
|
||||
return sodium_bin2hex(sodium_crypto_generichash($plaintext, $this->hashKey, 32));
|
||||
}
|
||||
|
||||
/**
|
||||
* Seal the master key with a passphrase (Argon2id). The result is what goes into the
|
||||
* repository config and the recovery kit.
|
||||
*
|
||||
* @return array{v:int, salt:string, ops:int, mem:int, nonce:string, key:string}
|
||||
*/
|
||||
public function wrap(#[\SensitiveParameter] string $passphrase): array {
|
||||
$salt = random_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES);
|
||||
$ops = SODIUM_CRYPTO_PWHASH_OPSLIMIT_MODERATE;
|
||||
$mem = SODIUM_CRYPTO_PWHASH_MEMLIMIT_MODERATE;
|
||||
$kek = sodium_crypto_pwhash(SODIUM_CRYPTO_SECRETBOX_KEYBYTES, $passphrase, $salt, $ops, $mem,
|
||||
SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13);
|
||||
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
|
||||
$sealed = sodium_crypto_secretbox($this->masterKey, $nonce, $kek);
|
||||
sodium_memzero($kek);
|
||||
return [
|
||||
'v' => 1,
|
||||
'salt' => sodium_bin2base64($salt, SODIUM_BASE64_VARIANT_ORIGINAL),
|
||||
'ops' => $ops,
|
||||
'mem' => $mem,
|
||||
'nonce' => sodium_bin2base64($nonce, SODIUM_BASE64_VARIANT_ORIGINAL),
|
||||
'key' => sodium_bin2base64($sealed, SODIUM_BASE64_VARIANT_ORIGINAL),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array{v:int, salt:string, ops:int, mem:int, nonce:string, key:string} $wrapped */
|
||||
public static function unwrap(array $wrapped, #[\SensitiveParameter] string $passphrase): self {
|
||||
$b64 = static fn (string $s): string => sodium_base642bin($s, SODIUM_BASE64_VARIANT_ORIGINAL);
|
||||
$kek = sodium_crypto_pwhash(SODIUM_CRYPTO_SECRETBOX_KEYBYTES, $passphrase, $b64($wrapped['salt']),
|
||||
$wrapped['ops'], $wrapped['mem'], SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13);
|
||||
$master = sodium_crypto_secretbox_open($b64($wrapped['key']), $b64($wrapped['nonce']), $kek);
|
||||
sodium_memzero($kek);
|
||||
if ($master === false) {
|
||||
throw new CryptoException('Wrong passphrase or damaged key');
|
||||
}
|
||||
return new self($master);
|
||||
}
|
||||
|
||||
public function __destruct() {
|
||||
sodium_memzero($this->masterKey);
|
||||
sodium_memzero($this->dataKey);
|
||||
sodium_memzero($this->hashKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Crypto;
|
||||
|
||||
/**
|
||||
* Streaming authenticated encryption with libsodium secretstream (XChaCha20-Poly1305).
|
||||
*
|
||||
* Object layout:
|
||||
* "NGB1" | secretstream header (24 bytes) | frame*
|
||||
* frame = uint32 big-endian ciphertext length | ciphertext
|
||||
* Each frame holds at most FRAME_SIZE plaintext bytes; the last frame carries TAG_FINAL, so a
|
||||
* truncated object fails to decrypt instead of yielding a shorter plaintext.
|
||||
*
|
||||
* Memory use is bounded by FRAME_SIZE, independent of the object size.
|
||||
*/
|
||||
final class StreamCipher {
|
||||
public const MAGIC = 'NGB1';
|
||||
public const FRAME_SIZE = 65536;
|
||||
private const HEADER_BYTES = SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_HEADERBYTES;
|
||||
private const ABYTES = SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_ABYTES;
|
||||
|
||||
public function __construct(
|
||||
private KeyRing $keys,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param resource $in readable stream (plaintext)
|
||||
* @param resource $out writable stream (ciphertext)
|
||||
* @return int number of ciphertext bytes written
|
||||
*/
|
||||
public function encrypt($in, $out, string $associatedData = ''): int {
|
||||
[$state, $header] = sodium_crypto_secretstream_xchacha20poly1305_init_push($this->keys->dataKey());
|
||||
$written = self::write($out, self::MAGIC . $header);
|
||||
|
||||
// Read one frame ahead so the last frame can be tagged FINAL.
|
||||
$current = self::readFull($in, self::FRAME_SIZE);
|
||||
do {
|
||||
$next = $current === '' ? '' : self::readFull($in, self::FRAME_SIZE);
|
||||
$tag = $next === ''
|
||||
? SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL
|
||||
: SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_MESSAGE;
|
||||
$cipher = sodium_crypto_secretstream_xchacha20poly1305_push($state, $current, $associatedData, $tag);
|
||||
$written += self::write($out, pack('N', strlen($cipher)) . $cipher);
|
||||
$current = $next;
|
||||
} while ($tag !== SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL);
|
||||
|
||||
sodium_memzero($state);
|
||||
return $written;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param resource $in readable stream (ciphertext)
|
||||
* @param resource $out writable stream (plaintext)
|
||||
* @return int number of plaintext bytes written
|
||||
* @throws CryptoException on a wrong key, tampering or truncation
|
||||
*/
|
||||
public function decrypt($in, $out, string $associatedData = ''): int {
|
||||
$head = self::readFull($in, strlen(self::MAGIC) + self::HEADER_BYTES);
|
||||
if (strlen($head) !== strlen(self::MAGIC) + self::HEADER_BYTES || !str_starts_with($head, self::MAGIC)) {
|
||||
throw new CryptoException('Not an NG Backup object');
|
||||
}
|
||||
$state = sodium_crypto_secretstream_xchacha20poly1305_init_pull(substr($head, strlen(self::MAGIC)), $this->keys->dataKey());
|
||||
|
||||
$written = 0;
|
||||
while (true) {
|
||||
$len = self::readFull($in, 4);
|
||||
if (strlen($len) !== 4) {
|
||||
throw new CryptoException('Object is truncated');
|
||||
}
|
||||
$size = unpack('N', $len)[1];
|
||||
if ($size < self::ABYTES || $size > self::FRAME_SIZE + self::ABYTES) {
|
||||
throw new CryptoException('Invalid frame size');
|
||||
}
|
||||
$cipher = self::readFull($in, $size);
|
||||
if (strlen($cipher) !== $size) {
|
||||
throw new CryptoException('Object is truncated');
|
||||
}
|
||||
$result = sodium_crypto_secretstream_xchacha20poly1305_pull($state, $cipher, $associatedData);
|
||||
if ($result === false) {
|
||||
throw new CryptoException('Decryption failed (wrong key or damaged data)');
|
||||
}
|
||||
[$plain, $tag] = $result;
|
||||
$written += self::write($out, $plain);
|
||||
if ($tag === SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (self::readFull($in, 1) !== '') {
|
||||
throw new CryptoException('Unexpected data after the final frame');
|
||||
}
|
||||
sodium_memzero($state);
|
||||
return $written;
|
||||
}
|
||||
|
||||
/** One-shot helpers for small objects (manifests, index). */
|
||||
public function encryptString(string $plain, string $associatedData = ''): string {
|
||||
$in = fopen('php://memory', 'w+b');
|
||||
fwrite($in, $plain);
|
||||
rewind($in);
|
||||
$out = fopen('php://memory', 'w+b');
|
||||
$this->encrypt($in, $out, $associatedData);
|
||||
rewind($out);
|
||||
return (string)stream_get_contents($out);
|
||||
}
|
||||
|
||||
public function decryptString(string $cipher, string $associatedData = ''): string {
|
||||
$in = fopen('php://memory', 'w+b');
|
||||
fwrite($in, $cipher);
|
||||
rewind($in);
|
||||
$out = fopen('php://memory', 'w+b');
|
||||
$this->decrypt($in, $out, $associatedData);
|
||||
rewind($out);
|
||||
return (string)stream_get_contents($out);
|
||||
}
|
||||
|
||||
/** fread() may return short reads on network streams; keep reading until $length or EOF. */
|
||||
private static function readFull($in, int $length): string {
|
||||
$buffer = '';
|
||||
while (strlen($buffer) < $length && !feof($in)) {
|
||||
$chunk = fread($in, $length - strlen($buffer));
|
||||
if ($chunk === false) {
|
||||
throw new CryptoException('Read error');
|
||||
}
|
||||
if ($chunk === '') {
|
||||
// Non-blocking stream without data yet, or EOF not yet flagged.
|
||||
if (feof($in)) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$buffer .= $chunk;
|
||||
}
|
||||
return $buffer;
|
||||
}
|
||||
|
||||
private static function write($out, string $data): int {
|
||||
$total = 0;
|
||||
$length = strlen($data);
|
||||
while ($total < $length) {
|
||||
$n = fwrite($out, $total === 0 ? $data : substr($data, $total));
|
||||
if ($n === false || $n === 0) {
|
||||
throw new CryptoException('Write error');
|
||||
}
|
||||
$total += $n;
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user