M5: deletion delay with trash, passphrase slots, ransomware documentation
- removed snapshots go to a trash on the location and their data is only freed after the deletion delay, which is chosen when the key is created (default 7 days, 1-365) and cannot be changed afterwards; any admin can bring them back (occ backup:trash, backup:trash:restore, web UI) - up to three passphrase slots open the same master key (like LUKS), so one person leaving or dying does not lock the company out; slot changes are written to every location, announced to all admins and require a new recovery kit confirmation, while backups keep running - occ backup:key:slots, backup:key:slot:add|replace|remove; key init takes --label and --delete-delay-days - web UI: deletion delay and first slot at key creation, slot management, trash per location with restore; all behind password confirmation - README: key, slots (safe / CTO / head of IT), and protection against ransomware: object lock in compliance mode (e.g. 90 days), credentials without delete rights, provider snapshots, off-site copy
This commit is contained in:
@@ -16,6 +16,20 @@
|
||||
</div>
|
||||
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
||||
|
||||
<template v-for="target in status.targets" :key="'trash' + target.id">
|
||||
<div v-if="trash[target.id]?.length" class="ngb-trash">
|
||||
<h3>{{ t('ng_backup', 'Trash of {name}', { name: target.name }) }}</h3>
|
||||
<p class="ngb-muted">{{ t('ng_backup', 'Removed restore points can be brought back until they are deleted permanently.') }}</p>
|
||||
<ul>
|
||||
<li v-for="item in trash[target.id]" :key="item.id">
|
||||
{{ item.time ? new Date(item.time).toLocaleString() : item.id }}{{ item.label ? ' – ' + item.label : '' }}
|
||||
<span class="ngb-muted">{{ t('ng_backup', 'removed by {by}, deleted after {date}', { by: item.by, date: new Date(item.deleteAfter * 1000).toLocaleDateString() }) }}</span>
|
||||
<NcButton variant="tertiary" @click="untrash(target, item)">{{ t('ng_backup', 'Restore') }}</NcButton>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<table v-if="status.runs.length" class="ngb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -50,6 +64,8 @@ import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import api from '../api.js'
|
||||
import { formatSize } from '../format.js'
|
||||
import { confirmPassword } from '@nextcloud/password-confirmation'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const props = defineProps({ status: { type: Object, required: true } })
|
||||
const emit = defineEmits(['changed'])
|
||||
@@ -57,6 +73,30 @@ const schedule = ref(props.status.schedule)
|
||||
const error = ref('')
|
||||
watch(() => props.status.schedule, (s) => { schedule.value = s })
|
||||
|
||||
const trash = reactive({})
|
||||
async function loadTrash() {
|
||||
for (const target of props.status.targets) {
|
||||
try {
|
||||
trash[target.id] = await api.trash(target.id)
|
||||
} catch (e) {
|
||||
trash[target.id] = []
|
||||
}
|
||||
}
|
||||
}
|
||||
watch(() => props.status.targets.map(tg => tg.id).join(','), loadTrash, { immediate: true })
|
||||
|
||||
async function untrash(target, item) {
|
||||
error.value = ''
|
||||
try {
|
||||
await confirmPassword()
|
||||
await api.untrash(target.id, item.id)
|
||||
await loadTrash()
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
error.value = e?.message || ''
|
||||
}
|
||||
}
|
||||
|
||||
const isRunning = (targetId) => props.status.runs.some(r => r.targetId === targetId && r.kind === 'full' && r.status === 'running')
|
||||
const targetName = (id) => props.status.targets.find(tg => tg.id === id)?.name ?? '#' + id
|
||||
|
||||
@@ -87,5 +127,8 @@ async function saveSchedule() {
|
||||
.ngb-table { width: 100%; border-collapse: collapse; }
|
||||
.ngb-table th, .ngb-table td { text-align: start; padding: 4px 8px; border-bottom: 1px solid var(--color-border); }
|
||||
.ngb-failed { color: var(--color-error-text); }
|
||||
.ngb-muted { color: var(--color-text-maxcontrast); }
|
||||
.ngb-trash { margin: 12px 0; }
|
||||
.ngb-trash h3 { font-weight: bold; }
|
||||
.ngb-done { color: var(--color-success-text); }
|
||||
</style>
|
||||
|
||||
+93
-39
@@ -4,34 +4,67 @@
|
||||
-->
|
||||
<template>
|
||||
<NcSettingsSection :name="t('ng_backup', 'Backup key and recovery kit')"
|
||||
:description="t('ng_backup', 'All backups are encrypted with one key. The server keeps it for scheduled backups; the recovery kit, protected by your passphrase, is what you need to restore on a new server.')">
|
||||
:description="t('ng_backup', 'All backups are encrypted with one key. The server keeps it for scheduled backups. To restore on a new server you need the recovery kit and one of the passphrases.')">
|
||||
<!-- 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="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') : ''" />
|
||||
<NcButton type="submit" variant="primary" :disabled="busy || pass1.length < 12 || pass1 !== pass2">
|
||||
<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)">
|
||||
{{ t('ng_backup', 'Create backup key') }}
|
||||
</NcButton>
|
||||
</form>
|
||||
|
||||
<template v-else>
|
||||
<p>{{ t('ng_backup', 'Key fingerprint: {fingerprint}', { fingerprint: status.key.fingerprint }) }}</p>
|
||||
<p>
|
||||
{{ t('ng_backup', 'Key fingerprint: {fingerprint}', { fingerprint: status.key.fingerprint }) }} ·
|
||||
{{ t('ng_backup', 'Removed backups are kept in the trash for {days} days', { days: status.key.deleteDelayDays }) }}
|
||||
</p>
|
||||
|
||||
<!-- 2. Download the kit and confirm -->
|
||||
<!-- Passphrase slots -->
|
||||
<h3>{{ t('ng_backup', 'Passphrases') }}</h3>
|
||||
<p class="ngb-muted">
|
||||
{{ t('ng_backup', 'Up to {max} passphrases can open the key, so one person leaving does not lock you out. For example: one in the safe, one with the CTO, one with the head of IT. Everyone keeps the same recovery kit and only knows their own passphrase.', { max: status.key.maxSlots }) }}
|
||||
</p>
|
||||
<table class="ngb-table">
|
||||
<tbody>
|
||||
<tr v-for="slot in status.key.slots" :key="slot.slot">
|
||||
<td>{{ t('ng_backup', 'Slot {n}', { n: slot.slot }) }}</td>
|
||||
<td><strong>{{ slot.label }}</strong></td>
|
||||
<td class="ngb-muted">{{ slot.created ? new Date(slot.created * 1000).toLocaleDateString() : '' }}</td>
|
||||
<td>
|
||||
<NcButton variant="tertiary" @click="openSlotForm(slot)">{{ t('ng_backup', 'New passphrase') }}</NcButton>
|
||||
<NcButton v-if="status.key.slots.length > 1" variant="tertiary" @click="removeSlot(slot)">{{ t('ng_backup', 'Remove') }}</NcButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<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.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 @click="slotForm = null">{{ t('ng_backup', 'Cancel') }}</NcButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Recovery kit -->
|
||||
<h3>{{ t('ng_backup', 'Recovery kit') }}</h3>
|
||||
<div v-if="!status.key.confirmation" class="ngb-confirm">
|
||||
<NcNoteCard type="warning">
|
||||
{{ t('ng_backup', 'Before the first backup: download the recovery kit and store it, together with your passphrase, somewhere safe outside this server.') }}
|
||||
<NcNoteCard :type="status.key.everConfirmed ? 'error' : 'warning'">
|
||||
{{ status.key.everConfirmed
|
||||
? t('ng_backup', 'The passphrases changed: download the new recovery kit, give a copy to every passphrase holder and confirm. Backups keep running meanwhile.')
|
||||
: t('ng_backup', 'Before the first backup: download the recovery kit and store it, together with the passphrase, somewhere safe outside this server.') }}
|
||||
</NcNoteCard>
|
||||
<NcButton @click="downloadKit">
|
||||
{{ t('ng_backup', 'Download recovery kit') }}
|
||||
</NcButton>
|
||||
<NcCheckboxRadioSwitch v-model="accepted" :disabled="!downloaded">
|
||||
{{ status.statement }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<NcButton variant="primary" :disabled="!downloaded || !accepted || busy" @click="confirm">
|
||||
{{ status.confirmationPhrase }}
|
||||
</NcButton>
|
||||
<NcButton @click="downloadKit">{{ t('ng_backup', 'Download recovery kit') }}</NcButton>
|
||||
<NcCheckboxRadioSwitch v-model="accepted" :disabled="!downloaded">{{ status.statement }}</NcCheckboxRadioSwitch>
|
||||
<NcButton variant="primary" :disabled="!downloaded || !accepted || busy" @click="confirm">{{ status.confirmationPhrase }}</NcButton>
|
||||
</div>
|
||||
<p v-else>
|
||||
{{ t('ng_backup', 'Recovery kit confirmed by {user} on {date}.', { user: status.key.confirmation.uid, date: new Date(status.key.confirmation.time * 1000).toLocaleString() }) }}
|
||||
@@ -46,52 +79,75 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { confirmPassword } from '@nextcloud/password-confirmation'
|
||||
import '@nextcloud/password-confirmation/style.css'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
|
||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||
import { confirmPassword } from '@nextcloud/password-confirmation'
|
||||
import '@nextcloud/password-confirmation/style.css'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import api from '../api.js'
|
||||
|
||||
const props = defineProps({ status: { type: Object, required: true } })
|
||||
const emit = defineEmits(['changed'])
|
||||
|
||||
const label = ref('')
|
||||
const pass1 = ref('')
|
||||
const pass2 = ref('')
|
||||
const delay = ref(props.status.key.deleteDelayDays)
|
||||
const slotForm = ref(null)
|
||||
const downloaded = ref(false)
|
||||
const accepted = ref(false)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const kitUrl = api.kitUrl()
|
||||
|
||||
async function downloadKit() {
|
||||
error.value = ''
|
||||
try {
|
||||
await confirmPassword()
|
||||
window.location.href = kitUrl
|
||||
downloaded.value = true
|
||||
} catch (e) {
|
||||
error.value = e?.message || ''
|
||||
}
|
||||
}
|
||||
|
||||
async function createKey() {
|
||||
/** Run fn after a password confirmation; report errors in the card. */
|
||||
async function guarded(fn) {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await confirmPassword()
|
||||
await api.initKey(pass1.value)
|
||||
pass1.value = pass2.value = ''
|
||||
await fn()
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
error.value = e?.message || ''
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createKey = () => guarded(async () => {
|
||||
await api.initKey(pass1.value, Number(delay.value), label.value || 'Slot 1')
|
||||
pass1.value = pass2.value = ''
|
||||
})
|
||||
|
||||
const downloadKit = () => guarded(async () => {
|
||||
window.location.href = kitUrl
|
||||
downloaded.value = true
|
||||
})
|
||||
|
||||
function openSlotForm(slot) {
|
||||
slotForm.value = { slot: slot?.slot ?? null, label: slot?.label ?? '', pass1: '', pass2: '' }
|
||||
}
|
||||
|
||||
const saveSlot = () => guarded(async () => {
|
||||
const f = slotForm.value
|
||||
if (f.slot === null) {
|
||||
await api.addSlot(f.pass1, f.label || 'Slot')
|
||||
} else {
|
||||
await api.replaceSlot(f.slot, f.pass1, f.label || null)
|
||||
}
|
||||
slotForm.value = null
|
||||
downloaded.value = accepted.value = false
|
||||
})
|
||||
|
||||
const removeSlot = (slot) => guarded(async () => {
|
||||
await api.removeSlot(slot.slot)
|
||||
downloaded.value = accepted.value = false
|
||||
})
|
||||
|
||||
async function confirm() {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
@@ -107,11 +163,9 @@ async function confirm() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ngb-muted { color: var(--color-text-maxcontrast); margin-inline-start: 8px; }
|
||||
.ngb-form, .ngb-confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 600px;
|
||||
}
|
||||
.ngb-muted { color: var(--color-text-maxcontrast); }
|
||||
.ngb-form, .ngb-confirm { display: flex; flex-direction: column; gap: 12px; max-width: 600px; margin: 8px 0; }
|
||||
.ngb-row { display: flex; gap: 8px; }
|
||||
.ngb-table td { padding: 2px 12px 2px 0; }
|
||||
h3 { margin-top: 16px; font-weight: bold; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user