forked from akai/readest
6e7c9d1395
* feat(sync): add bundled `settings` replica kind for cross-device prefs and credentials Adds a single-row `settings` replica that syncs a whitelist of `SystemSettings` fields across devices via per-field LWW (one entry per dot-namespaced path). Plaintext for theme / highlight colour / TTS configuration; encrypted (AES-GCM under the user's sync passphrase) for kosync / Readwise / Hardcover credentials. Highlights: - Push-side diff against an in-memory snapshot for plaintext paths and a localStorage SHA-256 hash for encrypted paths, so a refresh doesn't re-publish or re-prompt for the passphrase. - Pull-side cipher-fingerprint dedupe + per-row passphrase gate; decryption failures surface as toasts (wrong passphrase / orphan cipher) instead of silent drops. - Auto-recovery for orphaned ciphers: when a row references a saltId no longer in `replica_keys`, clear the local hash and re-encrypt under the current salt on the next save. - Single in-flight `/sync/replica-keys` fetch with a value cache to coalesce the boot-time burst of concurrent unlock callers. * fix(sync): guard settings dot-path helpers against prototype-polluting keys Reject `__proto__`, `constructor`, and `prototype` segments in the settings adapter's `readPath` / `writePath`. Every caller currently passes a constant from `SETTINGS_WHITELIST`, so the guard is purely defensive — but it silences the CodeQL prototype-pollution warning on PR #4094 and keeps the helpers safe if a future call site ever forwards an untrusted path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
244 lines
9.8 KiB
TypeScript
244 lines
9.8 KiB
TypeScript
/**
|
|
* Encryption middleware for replica adapters with `encryptedFields`.
|
|
*
|
|
* The publish path runs `encryptPackedFields` between adapter.pack and
|
|
* envelope creation; the pull path runs `decryptRowFields` on the row
|
|
* fields_jsonb before adapter.unpackRow sees it. Adapters themselves
|
|
* stay sync and see plaintext only.
|
|
*
|
|
* Encryption is best-effort: when the CryptoSession is locked, encrypted
|
|
* fields are silently dropped from the push (`encryptPackedFields`
|
|
* deletes them from the packed object) and decryption failures on pull
|
|
* leave the field absent (`decryptRowFields` deletes the cipher entry)
|
|
* so the adapter's unpack sees nothing rather than ciphertext-as-string.
|
|
* Local plaintext copies are preserved by the store's applyRemote
|
|
* merge — see customOPDSStore.applyRemoteCatalog.
|
|
*/
|
|
import { isSyncError, SyncError } from '@/libs/errors';
|
|
import { isCipherEnvelope } from '@/types/replica';
|
|
import type { CipherEnvelope, FieldsObject } from '@/types/replica';
|
|
import type { CryptoSession } from '@/libs/crypto/session';
|
|
import { eventDispatcher } from '@/utils/event';
|
|
import { stubTranslation as _ } from '@/utils/misc';
|
|
import { cryptoSession as defaultCryptoSession } from '@/libs/crypto/session';
|
|
|
|
/**
|
|
* Encrypt the named fields of a packed-fields object in place. Fields
|
|
* with undefined / empty values are skipped. When the session can't
|
|
* encrypt (locked, no passphrase, web crypto unavailable), the
|
|
* affected fields are deleted from the object so they don't leak as
|
|
* plaintext into fields_jsonb.
|
|
*/
|
|
export const encryptPackedFields = async (
|
|
packed: Record<string, unknown>,
|
|
encryptedFields: readonly string[] | undefined,
|
|
session: CryptoSession = defaultCryptoSession,
|
|
): Promise<void> => {
|
|
if (!encryptedFields || encryptedFields.length === 0) return;
|
|
if (!session.isUnlocked()) {
|
|
for (const f of encryptedFields) delete packed[f];
|
|
return;
|
|
}
|
|
for (const fieldName of encryptedFields) {
|
|
const value = packed[fieldName];
|
|
if (value === undefined || value === null || value === '') continue;
|
|
try {
|
|
packed[fieldName] = await session.encryptField(String(value));
|
|
} catch (err) {
|
|
// Encryption failure on a single field shouldn't block the push of
|
|
// the other fields. Drop this one and log.
|
|
console.warn(
|
|
`[replicaCrypto] failed to encrypt field "${fieldName}" — dropping from push`,
|
|
err,
|
|
);
|
|
delete packed[fieldName];
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Detect whether the row carries at least one cipher envelope in any of
|
|
* the named fields. The orchestrator uses this to decide whether to
|
|
* trigger a passphrase prompt before the decrypt loop runs — the
|
|
* common case (no encrypted credentials on the row) skips the prompt
|
|
* entirely.
|
|
*/
|
|
export const rowHasCipherFields = (
|
|
fields: FieldsObject,
|
|
encryptedFields: readonly string[] | undefined,
|
|
): boolean => {
|
|
if (!encryptedFields || encryptedFields.length === 0) return false;
|
|
for (const fieldName of encryptedFields) {
|
|
const envelope = fields[fieldName];
|
|
if (!envelope || typeof envelope !== 'object' || !('v' in envelope)) continue;
|
|
if (isCipherEnvelope((envelope as { v: unknown }).v)) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* Snapshot the cipher ciphertexts (the `c` slot of each cipher envelope)
|
|
* for the named fields, BEFORE decryptRowFields mutates them in place.
|
|
* Used by the orchestrator to detect when a cipher has changed since
|
|
* the last pull (rotation / password update on another device).
|
|
*/
|
|
export const captureCipherTexts = (
|
|
fields: FieldsObject,
|
|
encryptedFields: readonly string[] | undefined,
|
|
): Record<string, string> => {
|
|
const out: Record<string, string> = {};
|
|
if (!encryptedFields) return out;
|
|
for (const f of encryptedFields) {
|
|
const env = fields[f];
|
|
if (!env || typeof env !== 'object' || !('v' in env)) continue;
|
|
const v = (env as { v: unknown }).v;
|
|
if (isCipherEnvelope(v)) out[f] = (v as { c: string }).c;
|
|
}
|
|
return out;
|
|
};
|
|
|
|
/**
|
|
* True if any ciphertext in `current` differs from the corresponding
|
|
* entry in `lastSeen`. New cipher fields (not previously seen) count
|
|
* as changed — that's the fresh-device path and should prompt.
|
|
*/
|
|
export const cipherTextsChanged = (
|
|
current: Record<string, string>,
|
|
lastSeen: Record<string, string> | undefined,
|
|
): boolean => {
|
|
for (const [f, c] of Object.entries(current)) {
|
|
if (!lastSeen || lastSeen[f] !== c) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* After decryptRowFields runs, walk the named fields and return the
|
|
* cipher snapshot for those whose decryption succeeded (the field's
|
|
* `v` slot is now a string). Used by the orchestrator to update the
|
|
* local record's `lastSeenCipher` so the next pull compares against
|
|
* the most recently-decrypted cipher rather than re-prompting.
|
|
*/
|
|
export const collectDecryptSuccess = (
|
|
fields: FieldsObject,
|
|
beforeDecrypt: Record<string, string>,
|
|
): Record<string, string> => {
|
|
const out: Record<string, string> = {};
|
|
for (const f of Object.keys(beforeDecrypt)) {
|
|
const env = fields[f];
|
|
if (!env || typeof env !== 'object' || !('v' in env)) continue;
|
|
const v = (env as { v: unknown }).v;
|
|
if (typeof v === 'string') out[f] = beforeDecrypt[f]!;
|
|
}
|
|
return out;
|
|
};
|
|
|
|
/**
|
|
* Decrypt the named fields of a row's fields_jsonb in place. Each named
|
|
* field's CRDT envelope value (the `v` slot) is replaced with the
|
|
* decrypted plaintext so the adapter's unpackRow sees a plain value.
|
|
* Fields whose envelope value isn't a CipherEnvelope (e.g., the
|
|
* publishing device hadn't unlocked yet, or this is a metadata-only
|
|
* legacy row) are left untouched. Decrypt failures delete the field
|
|
* from fields_jsonb entirely.
|
|
*
|
|
* `onLocked` is invoked at most once per call when the session is
|
|
* locked AND a cipher field is encountered. The orchestrator wires
|
|
* this to the passphrase gate so a sync fresh device prompts the user
|
|
* before silently dropping the encrypted creds.
|
|
*/
|
|
export interface DecryptRowResult {
|
|
/**
|
|
* Field paths whose decrypt failed because the cipher envelope's
|
|
* `saltId` no longer exists server-side (the user / admin reset
|
|
* `replica_keys` out of band). The orchestrator hands these to the
|
|
* adapter so it can clear any persisted "already-published"
|
|
* fingerprint for the path — the next save then re-encrypts the
|
|
* (still locally-held) plaintext under the current salt and the
|
|
* orphaned cipher gets overwritten on the server.
|
|
*/
|
|
saltNotFound: string[];
|
|
}
|
|
|
|
export const decryptRowFields = async (
|
|
fields: FieldsObject,
|
|
encryptedFields: readonly string[] | undefined,
|
|
session: CryptoSession = defaultCryptoSession,
|
|
onLocked?: () => Promise<void>,
|
|
): Promise<DecryptRowResult> => {
|
|
if (!encryptedFields || encryptedFields.length === 0) return { saltNotFound: [] };
|
|
const saltNotFound: string[] = [];
|
|
let promptAttempted = false;
|
|
let lastFailureCode: string | null = null;
|
|
let failedFieldCount = 0;
|
|
for (const fieldName of encryptedFields) {
|
|
const envelope = fields[fieldName];
|
|
if (!envelope || typeof envelope !== 'object' || !('v' in envelope)) continue;
|
|
const v = (envelope as { v: unknown }).v;
|
|
if (!isCipherEnvelope(v)) continue;
|
|
// Locked session + cipher field: ask the gate to unlock once per
|
|
// decryptRowFields call. If the unlock succeeds, fall through to
|
|
// decrypt; if it fails (user cancelled, gate has no prompter),
|
|
// drop the field and preserve the local plaintext copy.
|
|
if (!session.isUnlocked() && onLocked && !promptAttempted) {
|
|
promptAttempted = true;
|
|
try {
|
|
await onLocked();
|
|
} catch {
|
|
// Ignore — the next isUnlocked() check below decides what to do.
|
|
}
|
|
}
|
|
if (!session.isUnlocked()) {
|
|
delete fields[fieldName];
|
|
continue;
|
|
}
|
|
try {
|
|
const plaintext = await session.decryptField(v as CipherEnvelope);
|
|
(envelope as { v: unknown }).v = plaintext;
|
|
} catch (err) {
|
|
const code = isSyncError(err) ? (err as SyncError).code : 'unknown';
|
|
// Loud + uniformly prefixed so it's easy to grep in production
|
|
// console output. AES-GCM failures (wrong passphrase) surface
|
|
// as DECRYPT; SHA-256 sidecar mismatches as INTEGRITY.
|
|
console.warn(
|
|
`[replicaCrypto] failed to decrypt field "${fieldName}" (${code}) — preserving local copy`,
|
|
err,
|
|
);
|
|
lastFailureCode = code;
|
|
failedFieldCount += 1;
|
|
if (code === 'SALT_NOT_FOUND') saltNotFound.push(fieldName);
|
|
delete fields[fieldName];
|
|
}
|
|
}
|
|
|
|
// One toast per call, surfaced after the loop so the user notices
|
|
// even if they don't watch the console.
|
|
// * DECRYPT / INTEGRITY: AES-GCM or sidecar verification failed —
|
|
// almost always "wrong sync passphrase entered on this device".
|
|
// Local plaintext copy is preserved.
|
|
// * SALT_NOT_FOUND: the row's cipher envelope references a salt
|
|
// that no longer exists in `replica_keys` server-side. This
|
|
// happens when the salts were deleted out-of-band (e.g.,
|
|
// manually) without also wiping the cipher envelopes — the
|
|
// proper Forgot-passphrase RPC does both atomically. The orphan
|
|
// The orchestrator hands the saltNotFound list back to the
|
|
// adapter so it can clear any "already-published" snapshot for
|
|
// those paths — the next save then re-encrypts under the
|
|
// current salt and overwrites the orphan.
|
|
if (failedFieldCount > 0 && lastFailureCode !== null) {
|
|
let message: string;
|
|
if (lastFailureCode === 'DECRYPT' || lastFailureCode === 'INTEGRITY') {
|
|
message = _('Wrong sync passphrase — synced credentials could not be decrypted');
|
|
} else if (lastFailureCode === 'SALT_NOT_FOUND') {
|
|
message = _(
|
|
'Sync passphrase data on the server was reset. Re-encrypting your credentials under the new passphrase…',
|
|
);
|
|
} else {
|
|
message = _('Failed to decrypt synced credentials');
|
|
}
|
|
eventDispatcher.dispatch('toast', { type: 'error', message });
|
|
}
|
|
|
|
return { saltNotFound };
|
|
};
|