feat: add expiring backups and live account checks
CI / verify (push) Canceled after 0s

This commit is contained in:
Sucukdeluxe
2026-09-01 15:43:20 +02:00
parent 96037f2e1c
commit 9e5c5126dd
19 changed files with 710 additions and 128 deletions
+65 -32
View File
@@ -4,9 +4,10 @@ const crypto = require('node:crypto');
const secretStore = require('./secret-store');
const { parseOnlineBackupKey } = require('./online-backup');
const STORED_ENTRY_KEYS = ['createdAt', 'encryptedKey', 'id'];
const STORED_LEGACY_ENTRY_KEYS = ['createdAt', 'encryptedKey', 'id'];
const STORED_EXPIRING_ENTRY_KEYS = ['createdAt', 'encryptedKey', 'expiresAt', 'id'];
const STORED_V1_DOCUMENT_KEYS = ['keys', 'version'];
const STORED_V2_DOCUMENT_KEYS = ['generation', 'keys', 'version'];
const STORED_GENERATED_DOCUMENT_KEYS = ['generation', 'keys', 'version'];
const KEYRING_ERROR_CODES = Object.freeze({
structure: 'KEYRING_STRUCTURE_INVALID',
unavailable: 'KEYRING_SECURE_STORAGE_UNAVAILABLE',
@@ -78,7 +79,8 @@ function createOnlineBackupKeyring({
decryptField = secretStore.decryptField,
isEncrypted = secretStore.isEncrypted,
parseKey = parseOnlineBackupKey,
fsImpl = fs.promises
fsImpl = fs.promises,
now = () => Date.now()
}) {
const directory = path.dirname(filePath);
const basename = path.basename(filePath);
@@ -128,7 +130,7 @@ function createOnlineBackupKeyring({
return { version: 1, generation: 0, keys: document.keys };
}
if (
hasExactKeys(document, STORED_V2_DOCUMENT_KEYS)
hasExactKeys(document, STORED_GENERATED_DOCUMENT_KEYS)
&& document.version === 2
&& Number.isSafeInteger(document.generation)
&& document.generation > 0
@@ -178,8 +180,10 @@ function createOnlineBackupKeyring({
}
function validateEntry(entry) {
const hasLegacyShape = hasExactKeys(entry, STORED_LEGACY_ENTRY_KEYS);
const hasExpiringShape = hasExactKeys(entry, STORED_EXPIRING_ENTRY_KEYS);
if (
!hasExactKeys(entry, STORED_ENTRY_KEYS)
(!hasLegacyShape && !hasExpiringShape)
|| !isCanonicalId(entry.id)
|| typeof entry.encryptedKey !== 'string'
|| !isEncrypted(entry.encryptedKey)
@@ -194,6 +198,16 @@ function createOnlineBackupKeyring({
return { issue: KEYRING_ERROR_CODES.structure, id: entry.id };
}
if (createdAt !== entry.createdAt) return { issue: KEYRING_ERROR_CODES.structure, id: entry.id };
let expiresAt = null;
if (hasExpiringShape && entry.expiresAt !== null) {
if (typeof entry.expiresAt !== 'string') return { issue: KEYRING_ERROR_CODES.structure, id: entry.id };
try {
expiresAt = normalizeTimestamp(entry.expiresAt);
} catch {
return { issue: KEYRING_ERROR_CODES.structure, id: entry.id };
}
if (expiresAt !== entry.expiresAt || expiresAt <= createdAt) return { issue: KEYRING_ERROR_CODES.structure, id: entry.id };
}
let key;
try {
key = decryptField(entry.encryptedKey);
@@ -213,6 +227,7 @@ function createOnlineBackupKeyring({
id: entry.id,
encryptedKey: entry.encryptedKey,
createdAt,
expiresAt,
key
}
};
@@ -300,22 +315,22 @@ function createOnlineBackupKeyring({
recovered: false
});
}
const v2States = states.filter(state => state.version === 2);
if (v2States.length > 0) {
const highestObservedGeneration = Math.max(...v2States.map(state => state.generation));
selectGeneration(v2States, highestObservedGeneration);
const validV2States = v2States.filter(state => !firstBlockingIssue(state));
if (validV2States.length > 0) {
const highestValidGeneration = Math.max(...validV2States.map(state => state.generation));
return selectGeneration(validV2States, highestValidGeneration);
const generatedStates = states.filter(state => state.version === 2);
if (generatedStates.length > 0) {
const highestObservedGeneration = Math.max(...generatedStates.map(state => state.generation));
selectGeneration(generatedStates, highestObservedGeneration);
const validGeneratedStates = generatedStates.filter(state => !firstBlockingIssue(state));
if (validGeneratedStates.length > 0) {
const highestValidGeneration = Math.max(...validGeneratedStates.map(state => state.generation));
return selectGeneration(validGeneratedStates, highestValidGeneration);
}
}
const legacyStates = states.filter(state => state.version === 1);
const validLegacyStates = legacyStates.filter(state => !firstBlockingIssue(state));
if (validLegacyStates.length > 0) return selectLegacyState(validLegacyStates);
if (v2States.length > 0) {
const highestObservedGeneration = Math.max(...v2States.map(state => state.generation));
return selectGeneration(v2States, highestObservedGeneration);
if (generatedStates.length > 0) {
const highestObservedGeneration = Math.max(...generatedStates.map(state => state.generation));
return selectGeneration(generatedStates, highestObservedGeneration);
}
return selectLegacyState(legacyStates);
}
@@ -398,7 +413,7 @@ function createOnlineBackupKeyring({
const contents = JSON.stringify({
version: 2,
generation,
keys: entries.map(({ id, encryptedKey, createdAt }) => ({ id, encryptedKey, createdAt }))
keys: entries.map(({ id, encryptedKey, createdAt, expiresAt }) => ({ id, encryptedKey, createdAt, expiresAt: expiresAt ?? null }))
});
const payload = canonicalPayload(parseDocument(contents));
const stagingPath = temporaryPath('staging');
@@ -427,22 +442,33 @@ function createOnlineBackupKeyring({
} catch {}
}
async function list() {
const state = await readState();
const entries = state.entries
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
.map(({ id, key, createdAt }) => Object.freeze({
id,
displayKey: `${key.slice(0, 9)}${key.slice(-4)}`,
createdAt
}));
return Object.freeze({
entries: Object.freeze(entries),
issues: Object.freeze([...state.issues])
function isExpired(entry) {
return entry.expiresAt !== null && new Date(entry.expiresAt).getTime() <= Number(now());
}
function list() {
return serialize(async () => {
const state = await readState();
const activeEntries = state.entries.filter(entry => !isExpired(entry));
if (activeEntries.length !== state.entries.length && !firstBlockingIssue(state)) {
await writeEntries(activeEntries, nextGeneration(state.generation));
}
const entries = activeEntries
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
.map(({ id, key, createdAt, expiresAt }) => Object.freeze({
id,
displayKey: `${key.slice(0, 9)}${key.slice(-4)}`,
createdAt,
expiresAt
}));
return Object.freeze({
entries: Object.freeze(entries),
issues: Object.freeze([...state.issues])
});
});
}
function prepare(key, createdAt) {
function prepare(key, createdAt, expiresAt = null) {
let parsed;
try {
parsed = parseKey(key);
@@ -458,10 +484,16 @@ function createOnlineBackupKeyring({
if (typeof encryptedKey !== 'string' || encryptedKey === key || !isEncrypted(encryptedKey)) {
throw issueError(KEYRING_ERROR_CODES.encrypt);
}
const normalizedCreatedAt = normalizeTimestamp(createdAt);
const normalizedExpiresAt = expiresAt === null ? null : normalizeTimestamp(expiresAt);
if (normalizedExpiresAt !== null && normalizedExpiresAt <= normalizedCreatedAt) {
throw issueError(KEYRING_ERROR_CODES.structure);
}
return Object.freeze({
id: parsed.id,
encryptedKey,
createdAt: normalizeTimestamp(createdAt)
createdAt: normalizedCreatedAt,
expiresAt: normalizedExpiresAt
});
}
@@ -482,7 +514,8 @@ function createOnlineBackupKeyring({
const state = await readState();
if (state.duplicateIds.has(id)) throw issueError(KEYRING_ERROR_CODES.duplicate);
const entry = state.entries.find(current => current.id === id);
if (entry) return entry.key;
if (entry && !isExpired(entry)) return entry.key;
if (entry) return null;
const matchingProblem = state.problems.find(problem => problem.id === id);
if (matchingProblem) throw issueError(matchingProblem.code);
const blockingIssue = firstBlockingIssue(state);