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 SignatureaCould 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:
- Každý soubor je zašifrován náhodným symetrickým klíčem (AES)
- Symetrický klíč je zašifrován RSA veřejným klíčem master páru
- Výsledek je uložen jako
.shareKeyvedle souboru - 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:
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):
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í):
$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:
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:
$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:
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:
// řá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:
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
$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á:
- Z
config.php.BAKodečtena správná hodnotainstanceid - Hodnota zapsána zpět do
config.php 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:
$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:
// 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(obsahujeinstanceid+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.