Verze: Nextcloud 33.0.5.1, kontejner nextcloud:33-fpm
Dopad: Všechny soubory nepřístupné přes web, Android i desktop klienty


Příznaky

Po migraci na nový docker-compose.yaml přestaly fungovat soubory:

  • Webový klient: prázdný adresář nebo chyba při otevírání
  • Android klient: soubory se nezobrazují, nelze otevřít
  • Desktop klient: PROPFIND (adresářový výpis) vrací 207, stažení souborů selhává
  • NC log: Could not boot encryption: Bad Signature a Could not decrypt the private key from user "master_XXXXXXXX"

Nextcloud jako celek fungoval (přihlášení, nastavení, výpis adresářů). Nedostupné byly pouze šifrované soubory.


Kontext: proč nový docker-compose

Z důvodu potřeby provozu na slabším hardware jsme se rozhodli migrovat z nextcloud:33-apache na nextcloud:33-fpm s nginxem jako frontendem. O migraci na efektivnější web server (přepnutí na jiný Apache worker) jsem se už pokoušel, ale skončil v dependency hell a vracel se k dosavadnímu řešení. Rozhodl jsem se tedy zkusit přechod na nginx a nechat to udělat Claude code. A to byla chyba. Architektonicky to sice zvládl, ale zapomněl správně zmigrovat konfiguraci a Nextcloud při startu se zachoval nejlépe jak uměl. Výsledkem byl výrazný pokles spotřeby CPU a RAM. Při té příležitosti ale taky vznikl nový docker-compose.yaml s novou konfigurací a právě jeho nasazení incident způsobilo.


Pozadí: jak NC ukládá šifrovaný master klíč

Nextcloud server-side encryption v master key módu:

  1. Každý soubor je zašifrován náhodným symetrickým klíčem (AES)
  2. Symetrický klíč je zašifrován RSA veřejným klíčem master páru
  3. Výsledek je uložen jako .shareKey vedle souboru
  4. Master RSA privátní klíč je v data/files_encryption/OC_DEFAULT_MODULE/master_XXXXXXXX.privateKey

Pokud nejde dešifrovat master privátní klíč → nelze dešifrovat žádný shareKey → žádný soubor.

Dvouvrstvé šifrování master privátního klíče

Vnější vrstva — OC\Security\Crypto (ICrypto), formát hexEnc|hexIV|hexHMAC|3:

code
klíč  = HKDF(SHA-512, config.secret)[0:32]
šifra = phpseclib AES-CBC  ← POZOR: ne OpenSSL, phpseclib!

Po dešifrování: JSON {"key": "base64(vnitřní_data)", "uid": null}

Vnitřní vrstva — HBEGIN formát (AES-256-CTR, keyFormat:hash):

code
HBEGIN:cipher:AES-256-CTR:keyFormat:hash:encoding:binary:HEND
[binary ciphertext][00iv00][16B IV][00sig00][64B HMAC hex][xxx]

Klíč je odvozen přes PBKDF2 (100 000 iterací):

php
$salt = hash('sha256', $uid . $instanceId . $secret, true);
$key  = hash_pbkdf2('sha256', $secret, $salt, 100000, 32, true);
// $uid        = 'master_XXXXXXXX'   ← ID master klíče z DB
// $instanceId = z config.php        ← zde je problém
// $secret     = z config.php

Příčina

Při nasazení nového docker-compose.yaml 2026-06-15 byl kontejner spuštěn v bootstrapovacím módu — a NC automaticky vygeneroval nové instanceid:

code
Původní hodnota:  instanceid = 'oc1a2b3c4d5e'   (od dubna 2023, nezměněno 3 roky)
Po migraci:       instanceid = 'myservername'

Původní hodnota má typický tvar NC-generovaného ID (alfanumerický řetězec s prefixem oc). Nová hodnota je naopak čitelný název — podle všeho ji NC bootstrap dosadil z hostname nebo jiné konfigurace serveru, protože config.php nebyl správně přemontován a NC spustil výchozí inicializaci.

instanceid vstupuje do PBKDF2 soli → jiná hodnota = jiný odvozený klíč → dešifrování vnitřní vrstvy selže → “Bad Signature”.

Vnější ICrypto vrstva nebyla dotčena (závisí jen na secret, ne na instanceid). Proto šla vnější vrstva dešifrovat, vnitřní ne.


Postup zkoumání

1. Identifikace formátu souboru

Soubor master_XXXXXXXX.privateKey má formát hexEnc|hexIV|hexHMAC|3. Zpočátku jsem předpokládal starý NC pipe-delimited formát — ale |3 je přípona OC\Security\Crypto (ICrypto), ne legacy encryption.

Potvrzení úspěšným dešifrováním vnější vrstvy:

php
$inner = $crypto->decrypt($data);
// → {"key":"SEJFRzJOOmNpcGhlcjpBRVMtMjU2LUNUUjprZXlGb3JtYXQ6..."}
//     ^^^^^ base64 začátek HBEGIN dat
// ICrypto HMAC: MATCH ✓

2. Marné hledání správného PBKDF2 klíče

Vnitřní HBEGIN data se podařilo parsovat. Jenže odvozený klíč dával garbage:

code
derivedKey = PBKDF2(secret, sha256('' + 'oc1a2b3c4d5e' + secret), 100k iterací)
→ dešifrování: 3 272 bytů binárního odpadu

Zkoušel jsem systematicky: všechny kombinace instanceId (oc1a2b3c4d5e, myservername), různé iterace (100k, 600k, 65536, 1024), různé soli, raw secret jako klíč — HMAC neseděl ani jednou.

3. Klíčový objev: UID master klíče není prázdný string

Pohled do apps/encryption/lib/KeyManager.php:

php
// řádek 117:
$encryptedKey = $this->crypt->encryptPrivateKey(
    $keyPair['privateKey'],
    $this->getMasterKeyPassword(),
    $this->masterKeyId        // ← 'master_XXXXXXXX', NE ''
);

Všechny testy předpokládaly $uid = ''. Správná hodnota je 'master_XXXXXXXX' (ID uložené v NC databázi).

Se správným UID:

code
salt = sha256('master_XXXXXXXX' + 'oc1a2b3c4d5e' + secret)
key  = PBKDF2(secret, salt, 100 000 iterací, 32 bytů)
→ VALID RSA KEY! bits=4096 ✓

4. Ověření přes NC vlastní kód

php
$crypt->decryptPrivateKey($masterKey, $secret, 'master_XXXXXXXX');
// → RSA 4096 bitů ✓

S obnoveným instanceid = 'oc1a2b3c4d5e' NC správně dešifruje master klíč.


Oprava

Díky tomu, že při každé větší změně vytváříme zálohy konfigurace, měli jsme k dispozici config.php.BAK z předchozí verze (NC30, prosinec 2024). Záloha potvrdila správnou hodnotu instanceid. Oprava byla pak přímočará:

  1. Z config.php.BAK odečtena správná hodnota instanceid
  2. Hodnota zapsána zpět do config.php
  3. docker restart nextcloud

Výsledek: - Žádné “Bad Signature” chyby v logu po restartu - Desktop klient: synchronizace funguje ✓ - Android klient: soubory přístupné ✓ - Webový klient: soubory přístupné ✓


Zajímavosti

ICrypto nepoužívá OpenSSL

OC\Security\Crypto interně volá phpseclib AES, ne PHP openssl_encrypt/decrypt. Klíč se předává přes $cipher->setPassword() a je odvozený přes HKDF. Naivní pokus dešifrovat přes openssl_decrypt selže, i když máte správný klíč a správné parametry — voláte totiž jinou knihovnu.

Formát |3 je ICrypto, ne legacy OC šifrování

Starší NC encryption modul (soubory shareKey, fileKey) má odlišný formát. Soubor master privátního klíče je obalený ICryptem a vypadá jako hexEnc|hexIV|hexHMAC|3 — stejná přípona jako u starého formátu, ale jde o zcela jinou vrstvu.

NC ukládá klíče jako JSON + base64 + ICrypto

lib/private/Encryption/Keys/Storage.php:

php
$this->setKey($path, [
    'key' => base64_encode($key),
    'uid' => null,
]);

Celý JSON je pak zabalen do ICrypto. Proto getSystemUserKey() vrací base64_decode(json["key"]) — raw HBEGIN binární data, ne JSON.

HMAC v HBEGIN formátu závisí na version + position

Podpis v HBEGIN formátu:

php
// Crypt.php::createSignature():
$passPhrase = hash('sha512', $passPhrase . 'a', true);
return hash_hmac('sha256', $data, $passPhrase);

// kde $passPhrase = derivedKey . '_' . $version . '_' . $position
// pro decryptPrivateKey: version=0, position=0 → suffix '_0_0'

Bez znalosti suffixu _0_0 (a 'a' na konci) HMAC nesedí. NC ale nejdřív zkusí nový formát (_0_0), pak starý (00), a pokud selžou oba, při enforceSignature=false pokračuje dál a rovnou zkusí dešifrovat.

Změna instanceid může být u Nextcloud kritická

NC nevaruje, že ztráta instanceid okamžitě znepřístupní všechna šifrovaná data. Při startu bez config.php (nebo s neplatným) bootstrap tiše vygeneruje nové instanceid a uloží ho. Záloha config.php — zejména instanceid a secret — je pro provoz šifrovaného NC existenčně důležitá.


Prevence

  • Před každou migrací zálohovat config.php (obsahuje instanceid + secret)
  • Po velké migraci ihned funkční test: stáhnout jeden soubor přes WebDAV
  • V docker-compose.yaml mountovat config/ jako bind mount, ne Docker volume — bootstrap pak nemůže přepsat existující soubor novým

Napsáno 2026-06-18. NC 33.0.5.1, PHP 8.3, nextcloud:33-fpm.