Passphrases of at least 16 characters; explain that the location holds the key too

This commit is contained in:
2026-10-16 00:07:39 +02:00
parent c7cbc0b058
commit 629b8dd315
7 changed files with 36 additions and 16 deletions
+4
View File
@@ -14,6 +14,10 @@ instead carried by the app description, the git tag, the release, and this file.
which location, setup and size you used, and how it went.
### Changed
- New passphrases must be at least 16 characters (was 12). The location stores the key
encrypted with each passphrase, so the passphrase is what really protects the backups.
Existing passphrases keep working. The README and the how-to now say so, and why the
recovery kit must still be kept off the server.
- Dates and times in the web interface follow the locale in the user's Nextcloud settings instead
of the browser's language, so a 24-hour locale shows 16:00 rather than 4:00 PM.
- The list of backup runs (web interface and `occ backup:status`) shows the runs of the last 7
+13 -3
View File
@@ -51,9 +51,19 @@ All backups of an installation are encrypted with one master key.
- The **server** keeps the master key, encrypted with the instance secret, so scheduled backups
and restores work without anyone typing a passphrase. Any administrator can restore from the
web interface. Consequence: whoever fully controls the server can read the backups; whoever
only gets hold of the backup location cannot.
- For a **restore on a new server** (disaster recovery) you need the **recovery kit** and **one
passphrase**. The kit contains the master key, encrypted separately with each passphrase.
only gets hold of the backup location cannot, as long as the passphrases are strong (see below).
- The **location holds the key too**, by design (like restic and Borg in repokey mode): its
`config` file contains the master key, encrypted separately with each passphrase (Argon2id),
the same as in the recovery kit. Without a passphrase it is of no use, but whoever can read the
location can try passphrases offline. **Your passphrase is therefore the actual key**: it must
be at least 16 characters; five or more random words are a good choice.
- The **recovery kit** is your own copy of that encrypted key. Still keep it off the server and
off the location: an attacker who gets write access to the location can damage or delete its
`config` file on purpose, and without the kit that makes every backup there unreadable.
- For a **restore on a new server** (disaster recovery) you currently need the **recovery kit**
and **one passphrase**.
- Planned: an option to not store the key on the location at all. Then the kit is the only copy,
and losing it means losing every backup; that option will come with an explicit warning.
- Up to **three passphrases** ("slots") can open the key, so one person leaving or dying does not
lock the company out. A suggested setup:
+5 -2
View File
@@ -12,13 +12,16 @@ occ backup:key:kit --output=/var/tmp/ng_backup-kit.json
occ backup:key:confirm -u <your-admin-uid> --code=<code shown> --confirm="Yes, I confirm"
```
- The passphrase protects the backup. Keep it somewhere other than this server.
- The passphrase protects the backup: the location stores the key encrypted with it, so whoever
can read the location can try to guess it. Use at least 16 characters (five or more random
words work well) and keep it somewhere other than this server.
- Optional: extra passphrases for other people with `occ backup:key:slot:add --label="..."`.
Any one passphrase is enough to restore. Removing a slot later does not revoke access for someone
who kept an old kit and that passphrase (see "Known limitations" in the README).
- The recovery kit (`/var/tmp/ng_backup-kit.json` above) is also required for disaster recovery. The web
user writes it there; then move it off the server as root, next to the passphrase, and delete the
copy in `/var/tmp`. Without the kit and a passphrase, the backup cannot be read.
copy in `/var/tmp`. The location holds the same encrypted key, but keep the kit anyway: if
someone damages or deletes the key on the location, the kit is the only way back in.
## 2. Add a backup location
+1 -1
View File
@@ -50,7 +50,7 @@ HELP);
$pass = rtrim((string)@file_get_contents($file), "\r\n");
} else {
$helper = $this->getHelper('question');
$q = (new Question('Passphrase (min. 12 characters): '))->setHidden(true)->setHiddenFallback(false);
$q = (new Question('Passphrase (min. ' . \OCA\NgBackup\Service\KeyService::MIN_PASSPHRASE . ' characters): '))->setHidden(true)->setHiddenFallback(false);
$pass = (string)$helper->ask($input, $output, $q);
$again = (string)$helper->ask($input, $output, (new Question('Repeat passphrase: '))->setHidden(true)->setHiddenFallback(false));
if ($pass !== $again) {
+1 -1
View File
@@ -88,7 +88,7 @@ HELP;
return rtrim((string)@file_get_contents($file), "\r\n");
}
$h = $this->getHelper('question');
$p = (string)$h->ask($input, $output, (new Question('Passphrase (min. 12 characters): '))->setHidden(true)->setHiddenFallback(false));
$p = (string)$h->ask($input, $output, (new Question('Passphrase (min. ' . \OCA\NgBackup\Service\KeyService::MIN_PASSPHRASE . ' characters): '))->setHidden(true)->setHiddenFallback(false));
if ($p !== (string)$h->ask($input, $output, (new Question('Repeat: '))->setHidden(true)->setHiddenFallback(false))) {
throw new \InvalidArgumentException('The passphrases do not match');
}
+8 -5
View File
@@ -31,6 +31,11 @@ final class KeyService {
private const KEY_KIT_VERSION = 'recovery_kit_version';
private const KEY_KIT_CODE = 'recovery_kit_code';
public const DEFAULT_DELETE_DELAY = 7;
/**
* The location holds the passphrase-wrapped key too, so whoever can read it can try
* passphrases offline: the passphrase is what really protects the backups.
*/
public const MIN_PASSPHRASE = 16;
private ?KeyRing $cached = null;
@@ -56,9 +61,7 @@ final class KeyService {
if ($this->isInitialized()) {
throw new \RuntimeException('A backup key already exists');
}
if (strlen($passphrase) < 12) {
throw new \InvalidArgumentException('The passphrase must be at least 12 characters');
}
self::checkPassphrase($passphrase);
$keys = KeyRing::generate();
$this->appConfig->setValueString(Application::APP_ID, self::KEY_MASTER,
$this->crypto->encrypt(base64_encode($keys->exportMasterKey())), true, true);
@@ -234,8 +237,8 @@ final class KeyService {
}
private static function checkPassphrase(string $passphrase): void {
if (strlen($passphrase) < 12) {
throw new \InvalidArgumentException('The passphrase must be at least 12 characters');
if (strlen($passphrase) < self::MIN_PASSPHRASE) {
throw new \InvalidArgumentException('The passphrase must be at least ' . self::MIN_PASSPHRASE . ' characters');
}
}
+4 -4
View File
@@ -8,13 +8,13 @@
<!-- 1. Create the key -->
<form v-if="!status.key.initialized" class="ngb-form" @submit.prevent="createKey">
<NcTextField v-model="label" :label="t('ng_backup', 'Who keeps this passphrase (e.g. Safe, CTO)')" />
<NcPasswordField v-model="pass1" :label="t('ng_backup', 'Passphrase (at least 12 characters)')" autocomplete="new-password" />
<NcPasswordField v-model="pass1" :label="t('ng_backup', 'Passphrase (at least 16 characters)')" autocomplete="new-password" />
<NcPasswordField v-model="pass2" :label="t('ng_backup', 'Repeat passphrase')" autocomplete="new-password"
:error="pass2 !== '' && pass1 !== pass2" :helper-text="pass2 !== '' && pass1 !== pass2 ? t('ng_backup', 'The passphrases do not match') : ''" />
<NcTextField v-model="delay" type="number" min="1" max="365"
:label="t('ng_backup', 'Days a removed backup stays in the trash before it is really deleted')"
:helper-text="t('ng_backup', 'Protects against a malicious or mistaken cleanup. Cannot be changed later.')" />
<NcButton type="submit" variant="primary" :disabled="busy || pass1.length < 12 || pass1 !== pass2 || !(delay >= 1 && delay <= 365)">
<NcButton type="submit" variant="primary" :disabled="busy || pass1.length < 16 || pass1 !== pass2 || !(delay >= 1 && delay <= 365)">
{{ t('ng_backup', 'Create backup key') }}
</NcButton>
</form>
@@ -46,10 +46,10 @@
<NcButton v-if="!slotForm && status.key.slots.length < status.key.maxSlots" @click="openSlotForm(null)">{{ t('ng_backup', 'Add passphrase') }}</NcButton>
<form v-if="slotForm" class="ngb-form" @submit.prevent="saveSlot">
<NcTextField v-model="slotForm.label" :label="t('ng_backup', 'Who keeps this passphrase')" />
<NcPasswordField v-model="slotForm.pass1" :label="t('ng_backup', 'Passphrase (at least 12 characters)')" autocomplete="new-password" />
<NcPasswordField v-model="slotForm.pass1" :label="t('ng_backup', 'Passphrase (at least 16 characters)')" autocomplete="new-password" />
<NcPasswordField v-model="slotForm.pass2" :label="t('ng_backup', 'Repeat passphrase')" autocomplete="new-password" :error="slotForm.pass2 !== '' && slotForm.pass1 !== slotForm.pass2" />
<div class="ngb-row">
<NcButton type="submit" variant="primary" :disabled="busy || slotForm.pass1.length < 12 || slotForm.pass1 !== slotForm.pass2">{{ t('ng_backup', 'Save') }}</NcButton>
<NcButton type="submit" variant="primary" :disabled="busy || slotForm.pass1.length < 16 || slotForm.pass1 !== slotForm.pass2">{{ t('ng_backup', 'Save') }}</NcButton>
<NcButton @click="slotForm = null">{{ t('ng_backup', 'Cancel') }}</NcButton>
</div>
</form>