This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user