diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c2cb784..c9b410d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,4 +35,21 @@ jobs: - name: Audit runtime dependencies run: yarn audit --groups dependencies - run: yarn test --runInBand + - name: Start disposable backup replica set + run: | + docker run -d --name keeper-backup-mongo -p 127.0.0.1:27028:27017 \ + mongo:7.0@sha256:9854f7139445d766a9523571d6f047530c45547460ffcf8259eb2bf4264632ca \ + --replSet keeperQA --bind_ip_all + for attempt in $(seq 1 30); do + if docker exec keeper-backup-mongo mongosh --quiet --eval 'try { rs.status(); } catch (e) { if (e.code !== 94) throw e; rs.initiate({_id: "keeperQA", members: [{_id: 0, host: "127.0.0.1:27017"}]}); } quit(db.hello().isWritablePrimary ? 0 : 1)'; then + break + fi + sleep 2 + done + docker exec keeper-backup-mongo mongosh --quiet --eval 'quit(db.hello().isWritablePrimary ? 0 : 1)' + - name: Backup revision and account-isolation regression tests + run: yarn test:backup + - name: Remove disposable backup replica set + if: always() + run: docker rm -f keeper-backup-mongo || true # yarn test compiles TypeScript before Jest. SonarCloud is deferred. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c86d55..e2003cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ Use Node 22 and Yarn 1 with the committed lockfile. Run the local backend in `compose.local.yaml` or with a disposable MongoDB as described in [README.md](README.md). Keep tests and examples self-contained; do not depend on Keeper production accounts, buckets, FCM projects, or credentials. -Before opening a pull request, run `yarn test --runInBand` and the current-source scan in `scripts/check-source-secrets.py` using Gitleaks 8.30.1. Do not commit `.env`, service-account JSON, private keys, request bodies, user data, or screenshots from real users. Synthetic fixtures must be clearly disposable. +Before opening a pull request, run `yarn test --runInBand`, `yarn test:backup` against the disposable `keeperQA` replica set (see README) and the current-source scan in `scripts/check-source-secrets.py` using Gitleaks 8.30.1. Do not commit `.env`, service-account JSON, private keys, request bodies, user data, or screenshots from real users. Synthetic fixtures must be clearly disposable. Run `yarn audit --groups dependencies` for runtime dependencies; CI requires this check to pass. On 6 October 2026 the committed lockfile had no reported runtime advisories. A full audit also includes two unpatched denial-of-service advisories in Jest's development dependencies: [braces](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm) and [sprintf-js](https://github.com/advisories/GHSA-hp3w-g68c-fv3c). Their inputs here are checked-in test patterns and tool configuration, not HTTP requests. Run contribution tests in a disposable environment without hosted credentials; CI has a 15-minute job limit. These warnings are recorded, not suppressed, and should be reviewed when updating test tools. diff --git a/README.md b/README.md index 728676f..b8a65aa 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Relay is the database, notification and integration backend used by [Bitcoin Kee ## Local backend without hosted credentials -Use Docker Engine and Compose. The local stack has a disposable MongoDB and binds Relay only to loopback. Local mode blocks hosted integration routes. +Use Docker Engine and Compose. The local stack has a disposable single-member MongoDB replica set (`keeper-local`) and binds Relay only to loopback. Backup snapshot and repair use transactions, so a standalone MongoDB cannot serve those routes. Local mode blocks hosted integration routes. From a checkout of this clean source: @@ -15,22 +15,22 @@ curl --fail http://127.0.0.1:3000/health The health response reports `ready: true`, `mode: local`, and the capabilities available in this stack. Relay HTTP and Socket.IO are on `127.0.0.1:3000` and `127.0.0.1:4002`. The local database has a named volume; `docker compose -f compose.local.yaml down` keeps it. This stack does not send push notifications, sign Apple offers, use the testnet faucet, upload screenshots, or contact hosted purchase and AI services. Unsupported integration routes return `503`. -For a host Node setup, use Node 22, Python 3, Yarn 1 and a **disposable local** MongoDB. Copy `.env.example` to an ignored `.env`, set `LOCAL_DEV=true`, `ENVIRONMENT=DEVELOPMENT`, `BITCOIN_NETWORK=TESTNET`, and a local `DATABASE_URL`, then run `yarn install --frozen-lockfile --ignore-scripts`, `yarn compile`, and `yarn start`. Never copy a hosted environment file into this checkout. Local mode refuses mainnet, non-development environments and nonlocal MongoDB hosts. +For a host Node setup, use Node 22, Python 3, Yarn 1 and a **disposable local single-member replica set** of MongoDB. Copy `.env.example` to an ignored `.env`, set `LOCAL_DEV=true`, `ENVIRONMENT=DEVELOPMENT`, `BITCOIN_NETWORK=TESTNET`, and a local `DATABASE_URL` with the replica-set name in `?replicaSet=...`, then run `yarn install --frozen-lockfile --ignore-scripts`, `yarn compile`, and `yarn start`. Never copy a hosted environment file into this checkout. Local mode refuses mainnet, non-development environments and nonlocal MongoDB hosts. For app development, configure the mobile app to use these loopback Relay and channel URLs (Android emulator host routing may require `adb reverse`). The optional setup below also starts a local testnet Signing Server. Supported local Relay flows require no production provider credentials. ## Pair with the public Signing Server -Use Docker Compose 2.24.4 or newer for this optional stack. The public Signing Server supports installation, compilation and unit tests on its own. Its local Docker adapter is published in [app PR #7014](https://github.com/KeeperCommunity/bitcoin-keeper/pull/7014), which is still under review. The following commands pin that public adapter and the public Signing Server revision it supports. The app PR's full bootstrap now also pins this public Relay source. +Use Docker Compose 2.24.4 or newer for this optional stack. The public Signing Server supports installation, compilation and unit tests on its own. Its local Docker adapter is published in [app PR #7030](https://github.com/KeeperCommunity/bitcoin-keeper/pull/7030), which is still under review. The following commands pin that public adapter and the public Signing Server revision it supports. The app PR's full bootstrap now also pins this public Relay source. From inside this Relay checkout: ```sh RELAY_SOURCE_DIR="$PWD" -git clone --filter=blob:none --sparse --branch codex/contributor-dev-environment https://github.com/KeeperCommunity/bitcoin-keeper.git ../keeper-public-local-adapter +git clone --filter=blob:none --sparse --branch codex/r3-public-contributor-sprint https://github.com/KeeperCommunity/bitcoin-keeper.git ../keeper-public-local-adapter cd ../keeper-public-local-adapter git sparse-checkout set dev/local-backend -git checkout 7519452ce32edd6579284ec278c6ae98b796b931 +git checkout 58208450c88c0aabd4a44df52630c35843c16484 ./dev/local-backend/dev prepare-signing export SIGNING_SOURCE_DIR="$PWD/dev/local-backend/.sources/signing" cd "$RELAY_SOURCE_DIR" @@ -39,11 +39,19 @@ curl --fail http://127.0.0.1:3000/health curl --fail http://127.0.0.1:3003/health ``` -This prepares Signing Server revision `b82dc4f4a8f75676b70b12545d59c7906556a92b`, verifies the adapter checksums, and starts the services behind a loopback gateway on ports 3000, 4002 and 3003. Relay, Mongo and Signing Server share an internal network with no external egress; only the gateway has host access. Signing Server creates independent disposable development identities in its named volume; preserve that volume together with Mongo when reusing local records. Do not export its contents. Local push delivery and scheduled jobs are disabled; email is captured inside the local signing volume and is never delivered. +This prepares Signing Server revision `3cc7728c27328e7b0358d1f91a32d8d13feba90a`, verifies the adapter checksums, and starts the services behind a loopback gateway on ports 3000, 4002 and 3003. Relay, Mongo and Signing Server share an internal network with no external egress; only the gateway has host access. Signing Server creates independent disposable development identities in its named volume; preserve that volume together with Mongo when reusing local records. Do not export its contents. Local push delivery and scheduled jobs are disabled; email is captured inside the local signing volume and is never delivered. -Stop this stack with `docker compose -f compose.local.yaml -f compose.local-with-signing.yaml down`. This retains the Mongo and signing volumes. The pairing verifies local service availability; bitcoin signing, recovery, inheritance and timer-dependent behavior need their own separate checks. For the complete app contributor stack, run `./dev/local-backend/dev up` from the pinned app checkout above. +Stop this stack with `docker compose -f compose.local.yaml -f compose.local-with-signing.yaml down`. This retains the Mongo and signing volumes. The pairing verifies local service availability; bitcoin signing, recovery, inheritance and timer-dependent behavior need their own separate checks. For the complete app contributor stack, use public contributor PR #7030 at `58208450c88c0aabd4a44df52630c35843c16484` and run `./dev/local-backend/dev up` from that app checkout. Its `dev/local-backend/sources.lock.json` pins Relay `ee935359f22ff7932ea0a62e9084d4b49801f4aa`, which includes the backup snapshot and repair protocol, revision guards, signer-map cleanup, and old-client incremental field aliases. The exact pins describe tested source; source PR approval and a successful local stack do not establish hosted deployment parity. -The paired setup was checked on October 6, 2026 with the public revisions above: both health endpoints, synthetic Relay record create/read/update/clear, Socket.IO polling and WebSocket connections, and rejection of an unauthenticated Signing Server setup request passed. These checks used isolated local ports and disposable volumes. +The earlier public-source paired setup was checked on October 6, 2026 with isolated local ports and disposable volumes: both health endpoints, synthetic Relay record create/read/update/clear, Socket.IO polling and WebSocket connections, and rejection of an unauthenticated Signing Server setup request passed. The app contributor CI verifies its pinned stack from public sources and also exercises backup snapshot/repair, stale revisions, content changing from A to B and back to A, and persistence across service restarts. Check the linked app PR for results at the pinned revision; the earlier setup check does not attest to a newer stack. + +## Backup snapshot and repair + +`POST /getBackupSnapshot` reads an account's encrypted recovery records in one database snapshot and returns a SHA-256 `revision` covering encrypted content plus a retained account mutation generation, the app image, account-scoped vault images, labels, and any unavailable vault IDs. It does not replace the backup. Invalid account identifiers return `400`; unavailable storage returns a fixed `503` response. + +`POST /repairAppBackup` requires the revision just read. A stale revision returns `409` with `BACKUP_CHANGED`; the existing backup stays intact. The generation advances transactionally on backup mutations and survives Delete Backup, so returning from content A to B and back to A never reuses an earlier token. Explicit current-state replacement must use the existing app verification/resubmission flow. Legacy full replacements cannot bypass this requirement after a revisioned repair. Collaborative accounts retain independently encrypted vault copies; no other account's ciphertext is substituted for a missing copy. + +The source includes the guarded backup implementation used by the mobile recovery protocol. Publishing it does not deploy a backend or migrate a hosted database. Before any hosted adoption, maintainers must review transaction/replica-set support and the account-scoped unique index with staging fixtures. ## Hosted integrations @@ -58,7 +66,7 @@ Deployment and provider permissions are maintainer responsibilities. Do not use ## Checks and contribution -Run `yarn test --runInBand` (which compiles first) and `python3 scripts/check-source-secrets.py` with Gitleaks 8.30.1. The database tests use `READINESS_MONGO_URL` pointing at a disposable MongoDB. See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md). +Run `yarn test --runInBand` (which compiles first) and `python3 scripts/check-source-secrets.py` with Gitleaks 8.30.1. The database tests use `READINESS_MONGO_URL` pointing at a disposable MongoDB. Run `yarn test:backup` with a disposable loopback replica set named `keeperQA` (default `127.0.0.1:27028`, or set `BACKUP_TEST_MONGO_URL` to its loopback address). These tests create and drop only uniquely named test databases; they never read `.env` or hosted credentials. CI runs both suites. See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md). ## License diff --git a/compose.local-with-signing.yaml b/compose.local-with-signing.yaml index 79dc53f..ea738ff 100644 --- a/compose.local-with-signing.yaml +++ b/compose.local-with-signing.yaml @@ -1,12 +1,6 @@ # Optional pairing with the public SigningServer local adapter. # Follow README.md to prepare that source before starting this stack. services: - mongo: - healthcheck: - test: [CMD, mongosh, --quiet, --eval, "quit(db.adminCommand('ping').ok ? 0 : 1)"] - interval: 5s - timeout: 5s - retries: 20 relay: ports: !reset [] environment: diff --git a/compose.local.yaml b/compose.local.yaml index bbaba65..57624d2 100644 --- a/compose.local.yaml +++ b/compose.local.yaml @@ -1,6 +1,12 @@ services: mongo: image: mongo:7.0@sha256:9854f7139445d766a9523571d6f047530c45547460ffcf8259eb2bf4264632ca + command: [mongod, --replSet, keeper-local, --bind_ip_all] + healthcheck: + test: [CMD, mongosh, --quiet, --eval, "try { rs.status(); } catch (e) { if (e.code !== 94) throw e; rs.initiate({_id: 'keeper-local', members: [{_id: 0, host: 'mongo:27017'}]}); } quit(db.hello().isWritablePrimary ? 0 : 1)"] + interval: 5s + timeout: 5s + retries: 20 volumes: - relay-local-mongo:/data/db networks: @@ -10,12 +16,13 @@ services: context: . dockerfile: Dockerfile.local depends_on: - - mongo + mongo: + condition: service_healthy environment: LOCAL_DEV: "true" ENVIRONMENT: DEVELOPMENT BITCOIN_NETWORK: TESTNET - DATABASE_URL: mongodb://mongo:27017/keeper-relay-local + DATABASE_URL: mongodb://mongo:27017/keeper-relay-local?replicaSet=keeper-local PORT: "3000" WS_PORT: "4002" ports: diff --git a/package.json b/package.json index 65d27f1..dc0bc1b 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dev": "yarn compile && node dist/index.js", "test": "npm run compile && jest ./dist --detectOpenHandles", "gcp-build": "tsc -p .", + "test:backup": "node --test tests/*.test.cjs", "check:secrets": "python3 scripts/check-source-secrets.py" }, "jest": { diff --git a/src/db.ts b/src/db.ts index 9cdee4c..0885707 100644 --- a/src/db.ts +++ b/src/db.ts @@ -129,6 +129,18 @@ class Database { vault: { type: Object, required: true }, }); + // Each app encrypts its copy with its own Recovery Key. Collaborative + // participants share the canonical vault ID, but must retain separate images. + private appVaultImageSchema = new Schema({ + vaultId: { type: String, required: true, index: true }, + vaultShellId: { type: String, index: true }, + appId: { type: String, required: true }, + isArchived: { type: Boolean, required: true, default: false }, + signerIds: [{ type: String, required: true }], + scheme: { type: {}, required: false }, + vault: { type: Object, required: true }, + }); + private appImageSchema = new Schema({ appId: { type: String, required: true, unique: true }, publicId: { type: String, required: true, unique: true }, @@ -139,6 +151,10 @@ class Database { subscription: { type: String, required: false }, version: { type: String, required: true }, nodes: [{ type: String, required: false }], + // Once repaired, unversioned full replacements must never overwrite this image. + backupRevisionRequired: { type: Boolean, default: false }, + // Retained by Delete Backup, preventing stale A → B → A revisions. + backupGeneration: { type: Number, default: 0, min: 0 }, }); private labelSchema = new Schema({ @@ -311,6 +327,7 @@ class Database { { unique: true }, ); this.articleChunkSchema.index({ ragTimestamp: -1 }); + this.appVaultImageSchema.index({ appId: 1, vaultId: 1 }, { unique: true }); this.faucetQuotaSchema.index({ appId: 1, utcDate: 1 }, { unique: true }); this.faucetQuotaSchema.index( { createdAt: 1 }, @@ -349,6 +366,14 @@ class Database { return mongoose.model("Vault Image", this.vaultImageSchema); }; + public getAppVaultImageModel = () => { + return mongoose.model( + "App Vault Image", + this.appVaultImageSchema, + "appVaultImages", + ); + }; + public getVaultMapModel = () => { return mongoose.model("Vault Map", this.vaultMapSchema); }; diff --git a/src/localDevelopment.ts b/src/localDevelopment.ts index 2ce8367..91828b7 100644 --- a/src/localDevelopment.ts +++ b/src/localDevelopment.ts @@ -27,6 +27,7 @@ const localDatabaseRoutes = new Set([ "/getvaultmetadata", "/getsigneridinfo", "/migratexfps", "/modifylabels", "/getmessages", "/getsubscriptiondetails", "/updatecontactskey", "/createremotekey", "/getremotekey", "/backupallsignersandvaults", + "/getbackupsnapshot", "/repairappbackup", "/deletebackup", "/updatecollaborativechannel", "/fetchcollaborativechannel", "/gethardwarereferrallinks", "/getactivecampaign", "/getadvisors", ]); diff --git a/src/routes/routes.ts b/src/routes/routes.ts index 7951e42..4aa2420 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -1,3 +1,4 @@ +import { BackupUpgradeRequired, InvalidBackupRequest, BackupConflict, getBackupSnapshot, repairAppBackup } from "../services/backupSnapshot"; import { Express, Router, Request, Response, response } from "express"; import config from "../config"; @@ -502,15 +503,22 @@ export default class Routes { if (!req.body.appId) { return res.status(400).json({ err: "Input param missing - app's id" }); } + // Older mobile clients send walletObject and signersObjects. + const hasField = (name: string) => Object.prototype.hasOwnProperty.call(req.body, name); + if ((hasField("walletsObject") && hasField("walletObject")) || + (hasField("signersObject") && hasField("signersObjects"))) { + return res.status(400).json({ updated: false, error: "Conflicting backup record fields" }); + } try { const result = await bhr.updateAppImage( req.body.appId, req.body.publicId, - req.body.walletsObject, + hasField("walletsObject") ? req.body.walletsObject : req.body.walletObject, req.body.subscription, req.body.version, - req.body.signersObject, + hasField("signersObject") ? req.body.signersObject : req.body.signersObjects, req.body.nodes, + req.body.replaceNodes === true, ); res.status(200).json(result); } catch (err) { @@ -555,32 +563,37 @@ export default class Routes { const updated = await bhr.updateVault( req.body.vaultId, req.body.vault, + req.body.appId ?? req.body.appID, + req.body.isArchived, + req.body.signersData, ); if (updated) { - res.status(200).json({ + return res.status(200).json({ updated: true, error: "", }); } - } - if (req.body.archiveVaultId) { - await bhr.archiveVault(req.body.archiveVaultId); + return res.status(503).json({ + updated: false, + error: "Backup update failed", + }); } const result = await bhr.addVaultImage( - req.body.appID, + req.body.appId ?? req.body.appID, req.body.vaultShellId, req.body.vaultId, req.body.scheme, req.body.vault, req.body.signersData, req.body.subscription, + req.body.archiveVaultId, + req.body.isArchived, ); res.status(200).json(result); - } catch (err) { - console.log(err); + } catch { res.status(400).json({ updated: false, - error: `"Error: ${err}`, + error: "Vault backup update failed", }); } }); @@ -604,8 +617,10 @@ export default class Routes { } else { res.status(400).json({ err: "no appImage" }); } - } catch (err) { - console.log(err); + } catch { + // Always terminate the request. The client must not mistake missing + // account metadata or a database failure for an empty recoverable image. + res.status(503).json({ err: "Backup temporarily unavailable" }); } }); @@ -1049,6 +1064,20 @@ export default class Routes { } }); + router.post("/getBackupSnapshot", async (req, res) => { + if (typeof req.body.appId !== "string" || !req.body.appId) return res.status(400).json({ error: "Invalid backup account" }); + try { return res.status(200).json(await getBackupSnapshot(req.body.appId)); } + catch { return res.status(503).json({ error: "Backup snapshot unavailable" }); } + }); + router.post("/repairAppBackup", async (req, res) => { + try { return res.status(200).json(await repairAppBackup(req.body)); } + catch (error) { + return res.status(error instanceof BackupConflict ? 409 : error instanceof InvalidBackupRequest ? 400 : 503).json({ + updated: false, error: error instanceof BackupConflict ? "BACKUP_CHANGED" : "BACKUP_NOT_UPDATED", + }); + } + }); + router.post("/backupAllSignersAndVaults", async (req, res) => { if (!req.body.appId) { return res.status(400).json({ err: "Input param missing - app's id" }); @@ -1057,8 +1086,10 @@ export default class Routes { const result = await bhr.backupAllSignersAndVaults(req.body); res.status(200).json(result); } catch (err) { - console.log("🚀 ~ Routes ~ router.post ~ err:", err); - res.status(400).json(err); + res.status(err instanceof BackupUpgradeRequired || err instanceof BackupConflict ? 409 : err instanceof InvalidBackupRequest ? 400 : 503).json({ + updated: false, + error: err instanceof BackupUpgradeRequired ? "BACKUP_UPGRADE_REQUIRED" : err instanceof BackupConflict ? "BACKUP_CHANGED" : "BACKUP_NOT_UPDATED", + }); } }); diff --git a/src/services/app.ts b/src/services/app.ts index b048cfd..580f35c 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -75,7 +75,8 @@ export const createNewApp = async ( const appImageInstance = new appImageModel({ appId, publicId, - wallets: [], + wallets: {}, + signers: {}, version: appversion, nodes: [], }); diff --git a/src/services/backupSnapshot.ts b/src/services/backupSnapshot.ts new file mode 100644 index 0000000..6c4b80a --- /dev/null +++ b/src/services/backupSnapshot.ts @@ -0,0 +1,483 @@ +import { createHash } from "crypto"; +import mongoose from "mongoose"; +import {setTimeout, clearTimeout} from "timers"; +import db from "../db"; + +// Hash encrypted recovery records plus the retained account mutation generation. +// Transport ordering and unrelated Mongoose metadata do not affect the token. +const canonical = (value: any): string => { + if (value === undefined || value === null) return "null"; + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (typeof value !== "object") return JSON.stringify(value); + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`; +}; +export class InvalidBackupRequest extends Error {} +export class BackupUpgradeRequired extends Error { + constructor() { super("Update the app before replacing this backup"); } +} +export class BackupConflict extends Error { + constructor() { super("Backup changed; check it again before updating"); } +} + +async function accountVaultImages(appId: string, vaultIds: string[], session: mongoose.ClientSession) { + const scoped = await db.getAppVaultImageModel().find({ appId, vaultId: { $in:vaultIds } }).session(session).lean() as any[]; + const resolved = new Map(scoped.map(v => [v.vaultId, v])); + const missing = Array.from(new Set(vaultIds)).filter(id => !resolved.has(id)); + // Never return a collaborator's encrypted image as another account's backup. + const legacy = await db.getVaultImageModel().find({ appId, vaultId: { $in:missing } }).session(session).lean() as any[]; + for (const image of legacy) resolved.set(image.vaultId, image); + return { + allVaultImages: scoped.concat(legacy), + unavailableVaultIds: missing.filter(id => !resolved.has(id)).sort(), + }; +} + +async function snapshot(appId: string, session: mongoose.ClientSession) { + const app = await db.getAppImageModel().findOne({ appId }).session(session).lean() as any; + const uniqueIds = (ids: string[]) => { + if (!Array.isArray(ids)) return ids; + const seen = new Set(); + return ids.filter(id => {if (seen.has(id)) return false; seen.add(id); return true;}); + }; + // Mongoose's default minimization can omit valid empty maps. Normalize only + // absent fields; malformed stored values remain visible to validation. + const appImage = app ? {...app, + wallets:app.wallets === undefined ? {} : app.wallets, + signers:app.signers === undefined ? {} : app.signers, + nodes:app.nodes === undefined ? [] : app.nodes, + vaults:uniqueIds(app.vaults === undefined ? [] : app.vaults), + labels:uniqueIds(app.labels === undefined ? [] : app.labels), + } : { appId, wallets: {}, signers: {}, nodes: [], vaults: [], labels: [] }; + const vaultIds = appImage.vaults || []; + const labelIds = appImage.labels || []; + const { allVaultImages, unavailableVaultIds } = await accountVaultImages(appId, vaultIds, session); + const labels = await db.getLabelModel().find({ id: { $in: labelIds } }).session(session).lean() as any[]; + if (new Set(labelIds).size !== labels.length) { + throw new Error("Incomplete backup; no replacement was made"); + } + const records = { + appId, generation: app ? {identity:String(app._id),version:app.backupGeneration || 0} : null, + wallets: appImage.wallets || {}, signers: appImage.signers || {}, nodes: appImage.nodes || [], + vaults: allVaultImages.map(v => ({ vaultId: v.vaultId, vault: v.vault, appId: v.appId, isArchived: !!v.isArchived })).sort((a,b) => a.vaultId.localeCompare(b.vaultId)), + unavailableVaultIds, + labels: labels.map(l => ({ id:l.id, content:l.content })).sort((a,b) => a.id.localeCompare(b.id)), + }; + const revision = createHash("sha256").update(canonical(records)).digest("hex"); + return { appImage, allVaultImages, unavailableVaultIds, labels, revision, exists: !!app }; +} + +let scopedVaultInitialization: Promise; +let retryScopedVaultIndex = false; +async function initializeScopedVaults() { + if (!scopedVaultInitialization) { + const model = db.getAppVaultImageModel(); + // Mongoose 5 caches a rejected init promise. Retry the required unique index + // through the public collection API after an initialization failure. + scopedVaultInitialization = Promise.resolve().then(async () => { + if (!retryScopedVaultIndex) await model.init(); + // Explicitly require uniqueness even when autoIndex is disabled. + await model.collection.createIndex({appId:1,vaultId:1}, {unique:true,maxTimeMS:15000} as any); + }); + } + const attempt = scopedVaultInitialization; + let deadline: ReturnType; + try { + await Promise.race([attempt, new Promise((_, reject) => { + deadline = setTimeout(() => reject(new Error("Backup storage initialization unavailable")), 15000); + })]); + } catch (error) { + if (scopedVaultInitialization === attempt) { + scopedVaultInitialization = undefined; + retryScopedVaultIndex = true; + } + throw error; + } finally {clearTimeout(deadline);} +} + +async function inTransaction(work: (session: mongoose.ClientSession) => Promise) { + await initializeScopedVaults(); + const session = await mongoose.startSession(); + let result: T; + try { + await session.withTransaction(async () => { result = await work(session); }, { + readConcern: { level: "snapshot" }, writeConcern: { w: "majority" }, maxCommitTimeMS: 15000, + }); + return result; + } finally { await session.endSession(); } +} + +async function advanceBackupGeneration(appId: string, session: mongoose.ClientSession) { + await db.getAppImageModel().updateOne({appId}, {$inc:{backupGeneration:1}}, {session}); +} + +// Legacy incremental writes also share the transaction/generation boundary. +export async function withBackupMutation(appId: string, work: (session: mongoose.ClientSession) => Promise) { + assertAccountId(appId); + return inTransaction(async session => { + const result = await work(session); + if (result !== false && (!result || (result as any).updated !== false)) await advanceBackupGeneration(appId, session); + return result; + }); +} + +function assertAccountId(appId: unknown): asserts appId is string { + if (typeof appId !== "string" || !appId) throw new InvalidBackupRequest("Invalid backup account"); +} + +export async function getBackupSnapshot(appId: string) { + assertAccountId(appId); + return inTransaction(session => snapshot(appId, session)); +} + +// Generic restore must be complete. Only explicit, revisioned local resubmission +// may fill unavailable images reported by getBackupSnapshot. +export async function getAppBackupVaultImages(appId: string, vaultIds: string[]) { + assertAccountId(appId); + return inTransaction(async session => { + const result = await accountVaultImages(appId, vaultIds, session); + if (result.unavailableVaultIds.length) throw new Error("Incomplete backup; no replacement was made"); + return result.allVaultImages; + }); +} + +// Incremental deletion removes only the requested IDs. Never save a previously +// read wallet/key map: a delayed deletion must preserve a newer repair or save. +export async function deleteBackupEntities(appId: string, wallets?: string[], signers?: string[]) { + if (typeof appId !== "string" || !appId) throw new InvalidBackupRequest("Invalid backup account"); + const safeIds = (ids: unknown) => ids == null || (Array.isArray(ids) && ids.every(id => + typeof id === "string" && id.length > 0 && id.length <= 512 && !/[.$]/.test(id) && + !["__proto__", "constructor", "prototype"].includes(id) + )); + if (!safeIds(wallets) || !safeIds(signers)) throw new InvalidBackupRequest("Invalid backup record IDs"); + return inTransaction(async session => { + const app = await db.getAppImageModel().findOne({ appId }).session(session).lean(); + if (!app) return { updated: false, error: "No app image" }; + const removals: Record = {}; + for (const id of wallets || []) removals[`wallets.${id}`] = ""; + for (const id of signers || []) removals[`signers.${id}`] = ""; + if (Object.keys(removals).length) { + const result = await db.getAppImageModel().updateOne({ appId }, { $unset: removals }, { session }); + if (result.modifiedCount) await advanceBackupGeneration(appId, session); + } + return { updated: true, error: "" }; + }); +} + +const validRecordId = (id: unknown): id is string => typeof id === "string" && id.length > 0 && + id.length <= 512 && !/[.$]/.test(id) && !["__proto__", "constructor", "prototype"].includes(id); + +function assertVaultSigners(signers: any[]) { + if (!Array.isArray(signers) || signers.some(s => !s || !validRecordId(s.signerId) || + typeof s.xfpHash !== "string" || !s.xfpHash)) throw new InvalidBackupRequest("Invalid vault signers"); +} + +async function writeVaultMaps(signers: any[], vaultId: string, session: mongoose.ClientSession) { + for (const signer of signers) { + await db.getVaultMapModel().updateOne({ signerId: signer.signerId }, { + $set: { signerId: signer.signerId, xfpHash: signer.xfpHash, vaultId }, + }, { upsert: true, session }); + } +} + +function vaultFields(image: any) { + return { vault:image.vault, signerIds:image.signerIds || [], isArchived:!!image.isArchived, + ...(image.vaultShellId ? {vaultShellId:image.vaultShellId} : {}), + ...(image.scheme ? {scheme:image.scheme} : {}), + }; +} + +async function writeVaultImage(appId: string, vaultId: string, fields: any, session: mongoose.ClientSession) { + await db.getAppVaultImageModel().updateOne({ appId, vaultId }, { + $set:{ appId, vaultId, ...fields }, + }, {upsert:true, session}); + const legacy = await db.getVaultImageModel().findOne({ vaultId }).session(session).lean() as any; + if (!legacy || !legacy.appId || legacy.appId === appId) { + await db.getVaultImageModel().updateOne({ vaultId }, { + $set:{ appId, vaultId, ...fields }, ...(legacy ? {$inc:{__v:1}} : {}), + }, {upsert:true, session}); + } else { + // The global image remains a legacy discovery record. Touch its version to + // serialize shared membership changes without replacing peer ciphertext. + await db.getVaultImageModel().updateOne({ vaultId }, {$inc:{__v:1}}, {session}); + } +} + +async function clearVaultMapsIfUnshared(appId: string, vaultId: string, session: mongoose.ClientSession) { + const peers = await db.getAppImageModel().find({appId:{$ne:appId},vaults:vaultId}).session(session).lean() as any[]; + for (const peer of peers) { + const images = await accountVaultImages(peer.appId, [vaultId], session); + if (images.allVaultImages.some(image => !image.isArchived)) return; + } + await db.getVaultMapModel().deleteMany({vaultId}).session(session); +} + +// A removed signer must stop resolving to this vault unless another active +// participant still has that signer in its own encrypted vault image. +async function pruneRemovedVaultMaps(vaultId: string, removedIds: string[], session: mongoose.ClientSession, excludedAppId?: string) { + const candidates = new Set(removedIds.filter(id => typeof id === "string" && id)); + if (!candidates.size) return; + const participants = await db.getAppImageModel().find({vaults:vaultId}).session(session).lean() as any[]; + for (const participant of participants) { + if (participant.appId === excludedAppId) continue; + const images = await accountVaultImages(participant.appId, [vaultId], session); + for (const image of images.allVaultImages) { + if (!image.isArchived && Array.isArray(image.signerIds)) { + for (const id of image.signerIds) candidates.delete(id); + } + } + } + if (candidates.size) { + await db.getVaultMapModel().deleteMany({vaultId, signerId:{$in:Array.from(candidates)}}).session(session); + } +} + +async function removeAccountVaults(appId: string, vaultIds: string[], session: mongoose.ClientSession) { + for (const vaultId of Array.from(new Set(vaultIds))) { + const owned = (await accountVaultImages(appId, [vaultId], session)).allVaultImages[0]; + const legacy = await db.getVaultImageModel().findOne({vaultId}).session(session).lean(); + // A real common-document write makes simultaneous participant unlinks retry + // before deciding whether the final global lookup/image can be removed. + if (legacy) await db.getVaultImageModel().updateOne({vaultId}, {$inc:{__v:1}}, {session}); + const other = await db.getAppImageModel().findOne({appId:{$ne:appId},vaults:vaultId}).session(session).lean(); + await clearVaultMapsIfUnshared(appId, vaultId, session); + await pruneRemovedVaultMaps(vaultId, owned?.signerIds || [], session, appId); + if (!other) { + await db.getVaultImageModel().deleteMany({vaultId}).session(session); + } + } + await db.getAppVaultImageModel().deleteMany({appId,vaultId:{$in:vaultIds}}).session(session); +} + +export async function createBackupVaultMap(signers: any[], vaultId: string) { + if (!validRecordId(vaultId)) throw new InvalidBackupRequest("Invalid vault ID"); + assertVaultSigners(signers); + return inTransaction(async session => { + const vault = await db.getVaultImageModel().findOne({ vaultId }).session(session).lean(); + if (!vault) throw new InvalidBackupRequest("No vault image"); + await db.getVaultImageModel().updateOne({vaultId}, {$inc:{__v:1}}, {session}); + await writeVaultMaps(signers, vaultId, session); + await db.getAppImageModel().updateMany({vaults:vaultId}, {$inc:{backupGeneration:1}}, {session}); + }); +} + +async function archiveBackupVaultInSession(vaultId: string, session: mongoose.ClientSession, appId?: string) { + const legacy = await db.getVaultImageModel().findOne({vaultId}).session(session).lean() as any; + if (!appId) { + const references = await db.getAppImageModel().find({vaults:vaultId}).session(session).lean() as any[]; + if (!legacy || references.length !== 1 || references[0].appId !== legacy.appId) return false; + } + appId = appId || legacy?.appId; + if (!appId) return false; + const result = await accountVaultImages(appId, [vaultId], session); + const existing = result.allVaultImages[0]; + if (!existing) return false; + await writeVaultImage(appId, vaultId, {...vaultFields(existing),isArchived:true}, session); + await clearVaultMapsIfUnshared(appId, vaultId, session); + await pruneRemovedVaultMaps(vaultId, existing.signerIds || [], session); + await advanceBackupGeneration(appId, session); + return true; +} + +export async function archiveBackupVault(vaultId: string) { + if (!validRecordId(vaultId)) throw new InvalidBackupRequest("Invalid vault ID"); + return inTransaction(session => archiveBackupVaultInSession(vaultId, session)); +} + +// Compatible incremental writer: membership, encrypted image, archive change +// and signer lookups commit together. Repeated saves do not duplicate IDs. +export async function saveBackupVault(data: any) { + if (!validRecordId(data.vaultId)) throw new InvalidBackupRequest("Invalid vault ID"); + if (data.isArchived !== undefined && typeof data.isArchived !== "boolean") throw new InvalidBackupRequest("Invalid archive state"); + if (data.signersData != null) assertVaultSigners(data.signersData); + if (data.archiveVaultId != null && (!validRecordId(data.archiveVaultId) || data.archiveVaultId === data.vaultId)) + throw new InvalidBackupRequest("Invalid archived vault ID"); + return inTransaction(async session => { + const legacy = await db.getVaultImageModel().findOne({vaultId:data.vaultId}).session(session).lean() as any; + if (!data.appId) { + const references = await db.getAppImageModel().find({vaults:data.vaultId}).session(session).lean() as any[]; + if (!legacy || references.length !== 1 || references[0].appId !== legacy.appId) throw new BackupUpgradeRequired(); + } + const appId = data.appId || legacy?.appId; + if (typeof appId !== "string" || !appId) throw new InvalidBackupRequest("Backup account missing"); + const existing = (await accountVaultImages(appId, [data.vaultId], session)).allVaultImages[0]; + if (data.isUpdate && !existing) return {updated:false,error:"No vault image"}; + const app = await db.getAppImageModel().findOne({ appId }).session(session).lean(); + if (!app) return { updated: false, error: "No app image" }; + const encryptedVault = data.vault || existing?.vault; + if (typeof encryptedVault !== "string" || !encryptedVault) throw new InvalidBackupRequest("Invalid encrypted vault"); + const signersData = data.signersData ?? (existing ? undefined : []); + if (data.archiveVaultId && !await archiveBackupVaultInSession(data.archiveVaultId, session, appId)) + throw new InvalidBackupRequest("No archived vault image"); + const isArchived = data.isArchived ?? !!existing?.isArchived; + await writeVaultImage(appId, data.vaultId, { + ...(existing ? vaultFields(existing) : {}), vault:encryptedVault, isArchived, + ...(signersData !== undefined ? { signerIds: signersData.map(s => s.signerId) } : {}), + ...(data.vaultShellId ? { vaultShellId: data.vaultShellId } : {}), + ...(data.scheme ? { scheme: data.scheme } : {}), + }, session); + if (isArchived) await clearVaultMapsIfUnshared(appId, data.vaultId, session); + else if (signersData !== undefined) await writeVaultMaps(signersData, data.vaultId, session); + const retainedIds = isArchived ? [] : signersData === undefined ? (existing?.signerIds || []) : signersData.map(s => s.signerId); + await pruneRemovedVaultMaps(data.vaultId, (existing?.signerIds || []).filter(id => !retainedIds.includes(id)), session); + await db.getAppImageModel().updateOne({ appId }, { $addToSet: { vaults: data.vaultId } }, { session }); + await advanceBackupGeneration(appId, session); + return { updated: true, error: "" }; + }); +} + +export async function deleteBackupVaults(appId: string, vaultIds: string[]) { + if (typeof appId !== "string" || !appId || !Array.isArray(vaultIds) || vaultIds.some(id => !validRecordId(id))) + throw new InvalidBackupRequest("Invalid vault deletion"); + return withBackupMutation(appId, async session => { + const app = await db.getAppImageModel().findOne({ appId }).session(session).lean() as any; + if (!app) return { updated: false, error: "No app image" }; + const owned = await db.getAppVaultImageModel().find({ appId, vaultId: { $in: vaultIds } }).session(session).lean() as any[]; + // Unlink the caller's shared membership, retaining global records that + // another participant still references. Also clean the caller's own orphans. + const requested = new Set(vaultIds); + const authorizedIds = Array.from(new Set([ + ...(app.vaults || []).filter((id: string) => requested.has(id)), ...owned.map(v => v.vaultId), + ])) as string[]; + await removeAccountVaults(appId, authorizedIds, session); + await db.getAppImageModel().updateOne({ appId }, { $pull: { vaults: { $in: vaultIds } } }, { session }); + return { updated: true }; + }); +} + +// The user's explicit "Delete" choice clears the encrypted backup while +// retaining the app identity and subscription record. All related records +// change together, so a failed delete cannot leave a partial backup behind. +export async function deleteAppBackup(appId: string) { + if (typeof appId !== "string" || !appId) throw new InvalidBackupRequest("Invalid backup account"); + return withBackupMutation(appId, async session => { + const app = await db.getAppImageModel().findOne({ appId }).session(session).lean() as any; + if (!app) throw new InvalidBackupRequest("No backup found"); + const vaultIds: string[] = app.vaults || []; + const labelIds: string[] = app.labels || []; + await removeAccountVaults(appId, vaultIds, session); + await db.getAppVaultImageModel().deleteMany({appId}).session(session); + for (const id of labelIds) { + const otherOwner = await db.getAppImageModel().findOne({ + appId: { $ne: appId }, labels: id, + }).session(session).lean(); + if (!otherOwner) await db.getLabelModel().deleteOne({ id }).session(session); + } + await db.getAppImageModel().updateOne({ appId }, { $set: { + wallets: {}, signers: {}, nodes: [], vaults: [], labels: [], + } }, { session }); + return { updated: true, error: "" }; + }); +} + +function assertPayload(data: any) { + const map = (value: any) => value && typeof value === "object" && !Array.isArray(value); + const safeId = (id: string) => id.length > 0 && id.length <= 512 && !/[.$]/.test(id) && !["__proto__", "constructor", "prototype"].includes(id); + if (!data || typeof data.appId !== "string" || !data.appId || typeof data.publicId !== "string" || !data.publicId || typeof data.version !== "string" || + !/^[a-f0-9]{64}$/.test(data.expectedRevision) || !map(data.walletObject) || !map(data.signersObject) || + !map(data.vaultObject) || !Array.isArray(data.nodes) || !Array.isArray(data.labels) || + (data.replaceCurrentState !== undefined && typeof data.replaceCurrentState !== "boolean")) throw new InvalidBackupRequest("Invalid backup request"); + for (const records of [data.walletObject,data.signersObject]) { + if (Object.entries(records).some(([id,value]) => !safeId(id) || typeof value !== "string" || !value)) throw new InvalidBackupRequest("Invalid encrypted record"); + } + if (data.nodes.some(n => typeof n !== "string" || !n)) throw new InvalidBackupRequest("Invalid encrypted node"); + if (data.labels.some(l => !l || typeof l.id !== "string" || !safeId(l.id) || typeof l.content !== "string" || !l.content) || + new Set(data.labels.map(l => l.id)).size !== data.labels.length) throw new InvalidBackupRequest("Invalid encrypted labels"); + for (const [id,v] of Object.entries(data.vaultObject) as [string,any][]) { + if (!safeId(id) || !v || typeof v !== "object" || Array.isArray(v) || v.vaultId !== id || typeof v.vault !== "string" || !v.vault || typeof v.isArchived !== "boolean" || + !Array.isArray(v.signersData) || v.signersData.some(s => !s || typeof s.signerId !== "string" || !safeId(s.signerId) || (typeof s.xfpHash !== "string" || !s.xfpHash))) throw new InvalidBackupRequest("Invalid encrypted vault"); + } +} + +// This endpoint never silently falls back to the legacy destructive replacement. +// Transactions atomically update the referenced records and app image. On a +// concurrent-write retry, the token is checked again against the new snapshot. +export async function repairAppBackup(data: any) { + return writeBackup(data, false); +} + +async function writeBackup(data: any, legacy: boolean) { + assertPayload(data); + return withBackupMutation(data.appId, async session => { + const current = await snapshot(data.appId, session); + if (legacy && current.appImage.backupRevisionRequired) throw new BackupUpgradeRequired(); + if (current.revision !== data.expectedRevision) throw new BackupConflict(); + const includesAll = (oldIds: string[], newIds: string[]) => oldIds.every(id => newIds.includes(id)); + // Only an explicit, revisioned repair may remove records. Old full writers + // still fail closed, including transaction retries after a newer repair. + if ((legacy || data.replaceCurrentState !== true) && + (!includesAll(Object.keys(current.appImage.wallets || {}), Object.keys(data.walletObject)) || + !includesAll(Object.keys(current.appImage.signers || {}), Object.keys(data.signersObject)) || + !includesAll(current.appImage.vaults || [], Object.keys(data.vaultObject)) || + !includesAll(current.appImage.labels || [], data.labels.map(l => l.id)))) throw new BackupConflict(); + const removedVaultIds = (current.appImage.vaults || []).filter((id: string) => !data.vaultObject[id]); + const newLabelIds = new Set(data.labels.map((l: any) => l.id)); + const removedLabelIds = (current.appImage.labels || []).filter((id: string) => !newLabelIds.has(id)); + if (removedVaultIds.length) { + await removeAccountVaults(data.appId, removedVaultIds, session); + } + for (const id of removedLabelIds) { + const otherOwner = await db.getAppImageModel().findOne({ + appId: { $ne:data.appId }, labels:id, + }).session(session).lean(); + if (!otherOwner) await db.getLabelModel().deleteOne({ id }).session(session); + } + for (const [vaultId,v] of Object.entries(data.vaultObject) as [string,any][]) { + await writeVaultImage(data.appId, vaultId, { + vault:v.vault, signerIds:v.signersData.map(s => s.signerId), isArchived:v.isArchived, + ...(v.scheme ? {scheme:v.scheme} : {}), + ...(typeof v.vaultShellId === "string" ? {vaultShellId:v.vaultShellId} : {}), + }, session); + if (v.isArchived) await clearVaultMapsIfUnshared(data.appId, vaultId, session); + } + // Write active maps after archived-map removal so iteration order cannot + // erase the replacement wallet's lookup entries. + for (const [vaultId,v] of Object.entries(data.vaultObject) as [string,any][]) { + if (!v.isArchived) for (const signer of v.signersData) { + await db.getVaultMapModel().updateOne({ signerId:signer.signerId }, { $set:{ ...signer,vaultId } }, {upsert:true,session}); + } + } + for (const [vaultId,v] of Object.entries(data.vaultObject) as [string,any][]) { + const previous = current.allVaultImages.find(image => image.vaultId === vaultId); + const retainedIds = v.isArchived ? [] : v.signersData.map(s => s.signerId); + await pruneRemovedVaultMaps(vaultId, (previous?.signerIds || []).filter(id => !retainedIds.includes(id)), session); + } + for (const label of data.labels) { + const otherOwner = await db.getAppImageModel().findOne({ + appId: { $ne:data.appId }, labels:label.id, + }).session(session).lean(); + if (otherOwner) throw new BackupConflict(); + await db.getLabelModel().updateOne({ id:label.id }, { $set:{ content:label.content } }, {upsert:true,session}); + } + // Updating this document also makes concurrent membership edits conflict + // with the transaction instead of being lost in a full-snapshot write. + await db.getAppImageModel().updateOne({ appId:data.appId }, { $set:{ + wallets:data.walletObject, signers:data.signersObject, nodes:data.nodes, + vaults:Object.keys(data.vaultObject), labels:data.labels.map(l => l.id), version:data.version, + ...(!legacy ? { backupRevisionRequired:true } : {}), + }, $setOnInsert:{ appId:data.appId, publicId:data.publicId, subscription:data.subscription } }, {upsert:true,session}); + return { updated:true }; + }); +} + +// Old clients have no revision token. Read their starting state, preserve archive +// metadata they do not send, then use the same atomic writer. The writer checks +// the upgrade marker inside the transaction, including concurrent-write retries. +// Omitting server-only records fails closed instead of deleting recoverable data. +export async function backupLegacyApp(data: any) { + if (!data || typeof data.appId !== "string" || !data.appId) throw new InvalidBackupRequest("Invalid backup account"); + const current = await getBackupSnapshot(data.appId); + if (current.appImage.backupRevisionRequired) throw new BackupUpgradeRequired(); + if (current.unavailableVaultIds.length) throw new BackupConflict(); + const vaultObject = Object.create(null); + for (const [id, value] of Object.entries(data.vaultObject || {}) as [string, any][]) { + vaultObject[id] = { ...value, vaultId:id, + isArchived: !!current.allVaultImages.find(v => v.vaultId === id)?.isArchived }; + } + return writeBackup({ ...data, expectedRevision:current.revision, vaultObject, + publicId:data.publicId || current.appImage.publicId, + version:data.version || current.appImage.version, + walletObject:data.walletObject || current.appImage.wallets || {}, + signersObject:data.signersObject || current.appImage.signers || {}, + nodes:data.nodes || current.appImage.nodes || [], labels:data.labels || [], + }, true); +} diff --git a/src/services/bhr.ts b/src/services/bhr.ts index 75d4dec..c530d50 100644 --- a/src/services/bhr.ts +++ b/src/services/bhr.ts @@ -1,3 +1,14 @@ +import { + backupLegacyApp, + deleteAppBackup, + deleteBackupEntities, + saveBackupVault, + createBackupVaultMap, + archiveBackupVault, + deleteBackupVaults, + getBackupSnapshot, + withBackupMutation, +} from "./backupSnapshot"; import { AppImage, AppSubscription } from "../interface"; import db from "../db"; import { getAppSubscriptionDetails } from "./app"; @@ -10,95 +21,59 @@ export const updateAppImage = async ( subscription, version, signersObject, - nodes + nodes, + replaceNodes = false ): Promise<{ updated: boolean; error?: string }> => { + if (typeof appId !== "string" || !appId) return {updated:false,error:"Invalid backup account"}; const appImageModel: any = db.getAppImageModel(); - const [appImage] = await appImageModel.find({ appId }); try { - //new App Image - if (!appImage) { - const appImageInstance = new appImageModel({ - appId, - publicId, - wallets: walletObject, - subscription, - version, - nodes, - }); - await appImageInstance.save(); - return { updated: true, error: "" }; - } - //update App Image - else { - if (publicId) appImage.public = publicId; - if (subscription) appImage.subscription = subscription; - if (version) appImage.version = version; - if (nodes.length) appImage.nodes = nodes; - if (walletObject) { - //wallets condition check based on subscription beofre adding + return await withBackupMutation(appId, async session => { + if (replaceNodes === true && (!Array.isArray(nodes) || nodes.some(node => typeof node !== "string" || !node))) { + return { updated: false, error: "Invalid encrypted nodes" }; + } + const appImage = await appImageModel.findOne({ appId }).session(session); + if (appImage && walletObject) { const appSubscription = await getAppSubscriptionDetails(appId); - if (appSubscription) { - const isValid = isAllowedToUpdate( - appSubscription, - appImage, - walletObject - ); - if (isValid) { - const updatedWallets = { - ...appImage.wallets, - ...walletObject, - }; - appImage.wallets = updatedWallets; - } else { - return { - updated: false, - error: - "Your current subscription plan does not allow to add more wallets", - }; - } - } else { - // todo add this return { updated: false, error: "App not found" }; - const updatedWallets = { - ...appImage.wallets, - ...walletObject, - }; - appImage.wallets = updatedWallets; - const updatedImage = await appImage.save((err) => { - if (err) { - console.log(err); - throw new Error(`Error occured while saving to database: ${err}`); - } - }); - return { updated: true, error: "" }; + if (appSubscription && !isAllowedToUpdate(appSubscription, appImage, walletObject)) { + return { updated: false, error: "Your current subscription plan does not allow to add more wallets" }; } } - if (signersObject) { - const updatedSigners = { - ...appImage.signers, - ...signersObject, - }; - appImage.signers = updatedSigners; - const updatedImage = await appImage.save((err) => { - if (err) { - console.log(err); - throw new Error(`Error occured while saving to database: ${err}`); + // Set individual records atomically. A delayed incremental save must not + // replace wallets/signers added by a concurrent repair or another device. + const changes: any = {}; + if (publicId) changes.publicId = publicId; + if (subscription) changes.subscription = subscription; + if (version) changes.version = version; + // Legacy clients attach [] to unrelated wallet/key updates. Only explicit + // node replacement may clear the list; preserve legacy nonempty updates. + if (Array.isArray(nodes) && (replaceNodes === true || nodes.length)) changes.nodes = nodes; + for (const [kind, records] of [["wallets", walletObject], ["signers", signersObject]] as any[]) { + for (const [id, value] of Object.entries(records || {})) { + if (!id || /[.$]/.test(id) || ["__proto__", "constructor", "prototype"].includes(id)) { + return { updated:false, error:"Invalid backup record ID" }; } - }); - return { updated: true, error: "" }; - } - await appImage.save((err) => { - if (err) { - console.log(err); - return { - updated: false, - error: `Error occured while saving to database: ${err}`, - }; + changes[`${kind}.${id}`] = value; } - }); - return { updated: true, error: "" }; - } - } catch (err) { - return { updated: false, error: `${err}` }; + } + const defaults: any = { appId }; + if (!walletObject || !Object.keys(walletObject).length) defaults.wallets = {}; + if (!signersObject || !Object.keys(signersObject).length) defaults.signers = {}; + if (!changes.nodes) defaults.nodes = []; + if (!appImage && (!publicId || !version)) return { updated:false, error:"Backup account missing" }; + // Old createNewApp records used [] for empty maps. Convert only empty + // containers in this transaction before setting a per-record dotted path. + const emptyLegacyMaps: any = {}; + for (const kind of ["wallets", "signers"]) { + if (Array.isArray(appImage?.[kind]) && appImage[kind].length === 0) emptyLegacyMaps[kind] = {}; + } + if (Object.keys(emptyLegacyMaps).length) await appImageModel.updateOne({appId}, {$set:emptyLegacyMaps}, {session}); + if (Object.keys(changes).length) { + await appImageModel.updateOne({ appId }, { $set:changes, $setOnInsert:defaults }, { upsert:true,runValidators:true,session }); + } + return { updated:true,error:"" }; + }); + } catch { + return { updated:false,error:"Backup update failed" }; } }; @@ -107,91 +82,18 @@ export const deleteAppImageEntity = async ( wallets, signers ): Promise<{ updated: boolean; error?: string }> => { - const appImageModel: any = db.getAppImageModel(); - const [appImage] = await appImageModel.find({ appId }); try { - if (!appImage) { - console.log("Something Went Wrong, could not find the app image"); - return { updated: false, error: "No app image" }; - } else { - if (signers?.length > 0) { - let appImageSigners = { ...appImage.signers }; - console.log({ appImageSigners }); - for (const signerId of signers) { - if (appImageSigners.hasOwnProperty(signerId)) { - delete appImageSigners[signerId]; - } - } - console.log({ appImageSigners }); - appImage.signers = appImageSigners; - } - if (wallets?.length > 0) { - let appImageWallets = { ...appImage.wallets }; - for (const walletId of wallets) { - if (appImageWallets.hasOwnProperty(walletId)) { - delete appImageWallets[walletId]; - } - } - appImage.wallets = appImageWallets; - } - await appImage.save((err) => { - if (err) { - console.log(err); - return { - updated: false, - error: `Error occured while saving to database: ${err}`, - }; - } else { - console.log("update"); - return { updated: true, error: "" }; - } - }); - return { updated: true, error: "" }; - } - } catch (err) { - console.log("Error", err); - return { updated: false, error: `${err}` }; + return await deleteBackupEntities(appId, wallets, signers); + } catch { + return { updated: false, error: "Backup deletion failed" }; } }; export const deleteVaults = async (appId, vaults) => { try { - const vaultImageModel: any = db.getVaultImageModel(); - const appImageModel: any = db.getAppImageModel(); - const [appImage] = await appImageModel.find({ appId }); - - if (appImage) { - console.log({ old: appImage.vaults }); - const updatedAppImageVaults = appImage.vaults.filter( - (vaultId) => !vaults.includes(vaultId) - ); - appImage.vaults = updatedAppImageVaults; - - await appImage.save((err) => { - if (err) { - console.log(err); - return { - updated: false, - error: `Error occured while saving to database: ${err}`, - }; - } - }); - for (const vaultId of vaults) { - vaultImageModel - .findOneAndDelete({ vaultId }) - .then((deletedEntity) => { - console.log("Deleted Vault entity"); - }) - .catch((error) => { - console.error("Error deleting entity:", error); - }); - } - - return { updated: true }; - } - } catch (err) { - console.log(err); - return { updated: false, error: `${err}` }; + return await deleteBackupVaults(appId, vaults); + } catch { + return { updated: false, error: "Vault backup deletion failed" }; } }; @@ -223,25 +125,7 @@ const isCreatingNewWallet = ( return isCreatingNewWallet; }; -export const createVaultMap = async (signersData: any[], vaultId) => { - const vaultImageMapModel: any = db.getVaultMapModel(); - for (let index in signersData) { - const [vaultMap] = await vaultImageMapModel.find({ - signerId: signersData[index].signerId, - }); - if (!vaultMap) { - const vaultMapInstance = new vaultImageMapModel({ - signerId: signersData[index].signerId, - xfpHash: signersData[index].xfpHash, - vaultId, - }); - vaultMapInstance.save(); - } else { - vaultMap.vaultId = vaultId; - vaultMap.save(); - } - } -}; +export const createVaultMap = createBackupVaultMap; export const addVaultImage = async ( appId, @@ -250,105 +134,56 @@ export const addVaultImage = async ( scheme, vault, signersData, - subscription + subscription, + archiveVaultId?, + isArchived? ) => { try { - const vaultImageModel: any = db.getVaultImageModel(); - const [vaultImage] = await vaultImageModel.find({ vaultId }); // old vault with same signer may exsist - if (!vaultImage) { - // update the appImage's vault filed with new vaultId - const appImageModel: any = db.getAppImageModel(); - const [appImage] = await appImageModel.find({ appId }); - const updatedVaults = [...appImage.vaults, vaultId]; - appImage.vaults = updatedVaults; - await appImage.save((err) => { - if (err) { - console.log(err); - throw new Error(`Error occured while saving to database: ${err}`); - } - }); - - //creating new vault image and adding the map - const signerIds = signersData.map((signer) => signer.signerId); - const vaultImageInstance = new vaultImageModel({ - appId, - vaultShellId, - vaultId, - signerIds, - scheme, - vault, - subscription, - }); - vaultImageInstance.save(); - createVaultMap(signersData, vaultId); - } else { - if (appId) { - vaultImage.appId = appId; - //updated link of vault for new appId - const appImageModel: any = db.getAppImageModel(); - const [appImage] = await appImageModel.find({ appId }); - const updatedVaults = [...appImage.vaults, vaultId]; - appImage.vaults = updatedVaults; - await appImage.save((err) => { - if (err) { - console.log(err); - throw new Error(`Error occured while saving to database: ${err}`); - } - }); - } - if (vaultShellId) vaultImage.vaultShellId = vaultShellId; - if (signersData) { - const signerIds = signersData.map((signer) => signer.signerId); - vaultImage.signerIds = signerIds; - createVaultMap(signersData, vaultId); - } - if (scheme) vaultImage.scheme = scheme; - if (vault) vaultImage.vault = vault; - if (subscription) vaultImage.subscription = subscription; - vaultImage.isArchived = false; - vaultImage.save(); - } - - return { updated: true, error: "" }; - } catch (err) { - console.log(err); - return { updated: false, error: `${err}` }; + return await saveBackupVault({ + appId, + vaultShellId, + vaultId, + scheme, + vault, + signersData, + subscription, + archiveVaultId, + isArchived, + }); + } catch { + return { updated: false, error: "Vault backup update failed" }; } }; export const archiveVault = async (vaultId) => { try { - const vaultImageModel: any = db.getVaultImageModel(); - const [vaultImage] = await vaultImageModel.find({ vaultId }); - if (vaultImage) { - vaultImage.isArchived = true; - vaultImage.save(); - } - const vaultImageMapModel: any = db.getVaultMapModel(); - await vaultImageMapModel.deleteMany({ vaultId }); - return true; - } catch (err) { - console.log(err); + return await archiveBackupVault(vaultId); + } catch { return false; } }; -export const updateVault = async (vaultId, vault) => { +export const updateVault = async (vaultId, vault, appId?, isArchived?, signersData?) => { try { - const vaultImageModel: any = db.getVaultImageModel(); - const [vaultImage] = await vaultImageModel.find({ vaultId }); - if (vaultImage) { - vaultImage.isArchived = false; - vaultImage.vault = vault; - vaultImage.save(); - } - return {}; - } catch (err) { - console.log(err); + const result = await saveBackupVault({ + appId, + vaultId, + vault, + isArchived, + signersData, + isUpdate: true, + }); + return result?.updated ? {} : false; + } catch { + return false; } }; +const isLookupIdentifier = (value: unknown): value is string => + typeof value === "string" && value.trim().length > 0; + export const getSignerIdInfo = async (signerId) => { + if (!isLookupIdentifier(signerId)) return false; const vaultMapModel: any = db.getVaultMapModel(); const [vaultMap] = await vaultMapModel.find({ signerId }); if (!vaultMap) { @@ -360,6 +195,12 @@ export const getSignerIdInfo = async (signerId) => { }; export const getVaultMetaData = async (xfpHash, signerId) => { + if ( + !isLookupIdentifier(xfpHash) || + (signerId !== undefined && !isLookupIdentifier(signerId)) + ) { + return { error: "No Vault for this signer Id" }; + } const vaultMapModel: any = db.getVaultMapModel(); let vault; if (signerId) { @@ -383,6 +224,7 @@ export const getVaultMetaData = async (xfpHash, signerId) => { }; export const getVaultImage = async (vaultId) => { + if (!isLookupIdentifier(vaultId)) return; const vaultImageModel: any = db.getVaultImageModel(); const [vaultImage] = await vaultImageModel.find({ vaultId }); if (!vaultImage) { @@ -394,40 +236,29 @@ export const getVaultImage = async (vaultId) => { }; export const getAppImage = async (appId, appversion) => { - const appImageModel: any = db.getAppImageModel(); - const labelModel: any = db.getLabelModel(); - let [appImage] = await appImageModel.find({ appId }); - if (!appImage) { - appImage = { - labels: [], - vaults: [], - nodes: [], - appId, - wallets: {}, - version: appversion, - signers: {}, - }; - } + const snapshot = await getBackupSnapshot(appId); + if (snapshot.unavailableVaultIds.length) throw new Error("Recovery data unavailable"); + const appImage = snapshot.exists + ? snapshot.appImage + : { ...snapshot.appImage, version: appversion }; let vaultImage; - let allVaultImages = []; - if (appImage.vaults.length > 0) { - for (const i in appImage.vaults) { - const { isVault } = await vaultCheck(appImage.vaults[i]); - const image = await getVaultImage(appImage.vaults[i]); - if (isVault) { - vaultImage = image; - } - allVaultImages.push(image); - } + const vaultIds = appImage.vaults || []; + const availableVaultImages = snapshot.allVaultImages; + const vaultsById = new Map(); + for (const image of availableVaultImages) vaultsById.set(image.vaultId, image); + const allVaultImages = []; + for (const vaultId of vaultIds) { + const image = vaultsById.get(vaultId); + if (!image) throw new Error("Recovery data unavailable"); + if (!image.isArchived) vaultImage = image; + allVaultImages.push(image); } const subscription = await getAppSubscriptionDetails(appId, true); const plan = getPlans().find((plan) => plan.level === subscription.level); - const labelIds = appImage.labels; - const labels = await labelModel.find({ id: labelIds }); return { appImage, vaultImage, - labels, + labels: snapshot.labels, subscription: { level: subscription.level, name: plan.name, @@ -442,6 +273,7 @@ export const getAppImage = async (appId, appversion) => { }; export const vaultCheck = async (vaultId) => { + if (!isLookupIdentifier(vaultId)) return { isVault: false }; try { const vaultImageModel: any = db.getVaultImageModel(); const [vaultImage] = await vaultImageModel.find({ vaultId }); @@ -478,22 +310,24 @@ export const migrateXfp = async ( signerChanges: SignerChange[] ) => { try { - const appImageModel: any = db.getAppImageModel(); - const [appImage] = await appImageModel.find({ appId }); - let signersObject = { ...appImage.signers }; - if (appImage) { - for (const change of signerChanges) { - if (signersObject[change.oldSignerId] !== undefined) { - delete signersObject[change.oldSignerId]; + return await withBackupMutation(appId, async session => { + const appImageModel: any = db.getAppImageModel(); + const [appImage] = await appImageModel.find({ appId }).session(session); + let signersObject = { ...appImage?.signers }; + if (appImage) { + for (const change of signerChanges) { + if (signersObject[change.oldSignerId] !== undefined) { + delete signersObject[change.oldSignerId]; + } + signersObject[change.newSignerId] = change.newSignerDetails; } - signersObject[change.newSignerId] = change.newSignerDetails; + appImage.signers = signersObject; + await appImage.save({session}); + return true; + } else { + return false; } - appImage.signers = signersObject; - await appImage.save(); - return true; - } else { - return false; - } + }); } catch (err) { console.log(err); return false; @@ -502,239 +336,58 @@ export const migrateXfp = async ( export const modifyLabels = async (appId, addLabels, deleteLabels) => { try { - const appImageModel: any = db.getAppImageModel(); - const labelModel: any = db.getLabelModel(); - let appImage = await appImageModel.findOne({ appId }); - - if (!appImage) { - throw new Error("App not found"); - } + return await withBackupMutation(appId, async session => { + const appImageModel: any = db.getAppImageModel(); + const labelModel: any = db.getLabelModel(); + let appImage = await appImageModel.findOne({ appId }).session(session); - if (addLabels && addLabels.length > 0) { - const newLabels = []; - for (const label of addLabels) { - if (!label || !label.content) { - continue; - } - // Check if label exists - const existingLabel = await labelModel.findOne({ id: label.id }); - if (existingLabel) { - newLabels.push(existingLabel); - } else { - // need to create that label - const createdLabels = await labelModel.create(label); - newLabels.push(createdLabels.id); - } + if (!appImage) { + throw new Error("App not found"); } - appImage.labels = appImage.labels || []; - appImage.labels.push(...newLabels); - await appImage.save(); - } - - if (deleteLabels && deleteLabels.length > 0) { - const deletedLabels = await labelModel.deleteMany({ - id: { $in: deleteLabels }, - }); - if (appImage.labels && appImage.labels.length > 0) { - appImage.labels = appImage.labels.filter( - (labelId) => !deleteLabels.includes(labelId.toString()) - ); - } - await appImage.save(); - } - - return { updated: true }; - } catch (error) { - console.error("Error modifying labels:", error); - throw new Error("An error occurred while modifying labels"); - } -}; - -export const backupAllSignersAndVaults = async (data) => { - const { - appId, - publicId, - walletObject, - signersObject, - vaultObject, - subscription, - version, - nodes, - labels, - } = data; - const appImageModel: any = db.getAppImageModel(); - let [appImage] = await appImageModel.find({ appId }); - const labelModel: any = db.getLabelModel(); - const vaultImageModel: any = db.getVaultImageModel(); - const vaultsList = []; - let isNewAppImage = false; - try { - if (!appImage) { - isNewAppImage = true; - appImage = {}; - } - - if (publicId) appImage.public = publicId; - if (subscription) appImage.subscription = subscription; - if (version) appImage.version = version; - if (walletObject) appImage.wallets = walletObject; - if (signersObject) appImage.signers = signersObject; - if (nodes) appImage.nodes = nodes; - - for (let vaultId in vaultObject) { - const { vaultShellId, scheme, signersData, vault } = vaultObject[vaultId]; - const [vaultImage] = await vaultImageModel.find({ vaultId }); // old vault with same signer may exist - if (!vaultImage) { - vaultsList.push(vaultId); - //creating new vault image and adding the ma - const signerIds = vaultObject[vaultId].signersData.map( - (signer) => signer.signerId - ); - const vaultImageInstance = new vaultImageModel({ - appId, - vaultShellId, - vaultId, - signerIds, - scheme, - vault, - subscription, - }); - vaultImageInstance.save(); - createVaultMap(signersData, vaultId); - } else { - if (appId) { - vaultImage.appId = appId; - vaultsList.push(vaultId); - } - if (vaultShellId) vaultImage.vaultShellId = vaultShellId; - if (signersData) { - const signerIds = signersData.map((signer) => signer.signerId); - vaultImage.signerIds = signerIds; - createVaultMap(signersData, vaultId); + if (addLabels && addLabels.length > 0) { + const newLabels = []; + for (const label of addLabels) { + if (!label || !label.content) { + continue; + } + // Check if label exists + const existingLabel = await labelModel.findOne({ id: label.id }).session(session); + if (existingLabel) { + newLabels.push(existingLabel.id); + } else { + // need to create that label + const createdLabels = await labelModel.create([label], {session}); + newLabels.push(createdLabels[0].id); + } } - if (scheme) vaultImage.scheme = scheme; - if (vault) vaultImage.vault = vault; - if (subscription) vaultImage.subscription = subscription; - vaultImage.isArchived = false; - vaultImage.save(); - } - } - - const deletedVaults = appImage?.vaults?.filter( - (vault) => !vaultsList.includes(vault) - ); - if (deletedVaults?.length > 0) { - for (const vaultId of deletedVaults) { - await vaultImageModel - .findOneAndDelete({ vaultId }) - .then((deletedEntity) => { - console.log("Deleted Vault entity"); - }) - .catch((error) => { - console.error("Error deleting entity:", error); - }); + appImage.labels = appImage.labels || []; + appImage.labels.push(...newLabels); + await appImage.save({session}); } - } - appImage.vaults = vaultsList; - - const newLabels = labels.filter( - (obj) => !appImage?.labels?.includes(obj.id) - ); - const deletedLabels = appImage?.labels?.filter( - (labelId) => !labels.some((appItem) => appItem.id === labelId) - ); - - if (newLabels?.length > 0) { - const finalLabels = []; - for (const label of newLabels) { - if (!label || !label.content) { - continue; - } - // Check if label exists - const existingLabel = await labelModel.findOne({ id: label.id }); - if (existingLabel) { - finalLabels.push(existingLabel.id); - } else { - // need to create that label - const createdLabels = await labelModel.create(label); - finalLabels.push(createdLabels.id); + if (deleteLabels && deleteLabels.length > 0) { + const deletedLabels = await labelModel.deleteMany({ + id: { $in: deleteLabels }, + }).session(session); + if (appImage.labels && appImage.labels.length > 0) { + appImage.labels = appImage.labels.filter( + (labelId) => !deleteLabels.includes(labelId.toString()) + ); } + await appImage.save({session}); } - appImage.labels = appImage?.labels || []; - appImage.labels.push(...finalLabels); - } - if (deletedLabels?.length > 0) { - await labelModel.deleteMany({ - id: { $in: deletedLabels }, - }); - appImage.labels = appImage?.labels?.filter( - (labelId) => !deletedLabels.includes(labelId.toString()) - ); - } - - if (isNewAppImage) { - appImage = new appImageModel({ - ...appImage, - publicId: appImage.public, - appId, - }); - } - - await appImage.save((err) => { - if (err) { - console.log(err); - return { - updated: false, - error: `Error occurred while saving to database: ${err}`, - }; - } + return { updated: true }; }); - return { updated: true, error: "" }; - } catch (err) { - console.log("🚀 ~ backupAllSignersAndVaults ~ err:", err); - throw new Error(err); + } catch (error) { + console.error("Error modifying labels:", error); + throw new Error("An error occurred while modifying labels"); } }; -export const deleteBackup = async (appId) => { - const appImageModel: any = db.getAppImageModel(); - let [appImage] = await appImageModel.find({ appId }); - const labelModel: any = db.getLabelModel(); - const vaultImageModel: any = db.getVaultImageModel(); - const vaultMapModel: any = db.getVaultMapModel(); - - if (!appImage) { - throw new Error("no backup found"); - } - - try { - appImage.nodes = []; - appImage.wallets = []; - appImage.signers = []; - for (const labelId of appImage.labels) { - await labelModel.findOneAndDelete({ id: labelId }); - } +// Keep the legacy route, but never perform an unversioned destructive save. +export const backupAllSignersAndVaults = backupLegacyApp; - for (const vaultId of appImage.vaults) { - await vaultImageModel.findOneAndDelete({ vaultId }); - await vaultMapModel.deleteMany({ vaultId }); - } - - appImage.labels = []; - appImage.vaults = []; - await appImage.save((err) => { - if (err) { - console.log(err); - throw new Error(err); - } - }); - return { updated: true, error: "" }; - } catch (error) { - console.log("🚀 ~ deleteBackup ~ error:", error); - throw new Error(error); - } -}; +export const deleteBackup = deleteAppBackup; diff --git a/src/tests/security/localBackupRoutes.test.ts b/src/tests/security/localBackupRoutes.test.ts new file mode 100644 index 0000000..4c71e07 --- /dev/null +++ b/src/tests/security/localBackupRoutes.test.ts @@ -0,0 +1,26 @@ +import express from "express"; +import { AddressInfo } from "net"; +import { localDevelopmentRoutes } from "../../localDevelopment"; + +it("allows disposable backup routes while hosted integrations remain unavailable", async () => { + const app = express(); + app.use(express.json(), localDevelopmentRoutes); + app.post("/getBackupSnapshot", (_req, res) => res.json({ revision: "synthetic-revision" })); + app.post("/repairAppBackup", (_req, res) => res.json({ updated: true })); + app.post("/offer", (_req, res) => res.json({ shouldNotReachProvider: true })); + const server = await new Promise>(resolve => { + const listener = app.listen(0, "127.0.0.1", () => resolve(listener)); + }); + const baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + try { + for (const route of ["getBackupSnapshot", "repairAppBackup"]) { + const response = await fetch(`${baseUrl}/${route}`, { method: "POST", body: "{}", headers: { "Content-Type": "application/json" } }); + expect(response.status).toBe(200); + } + const response = await fetch(`${baseUrl}/offer`, { method: "POST" }); + expect(response.status).toBe(503); + expect((await response.json()).error).toBe("LOCAL_INTEGRATION_UNAVAILABLE"); + } finally { + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + } +}); diff --git a/tests/backupSnapshot.test.cjs b/tests/backupSnapshot.test.cjs new file mode 100644 index 0000000..d476fd8 --- /dev/null +++ b/tests/backupSnapshot.test.cjs @@ -0,0 +1,994 @@ +const {test,before,after} = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const path = require('node:path'); +const ts = require('typescript'); +const mongoose = require('mongoose'); +const crypto = require('node:crypto'); +// Deliberately ignore .env and production integration config. Only disposable +// loopback Mongo is accepted, and only this uniquely named test DB is dropped. +const database = `keeper_recovery_test_${process.pid}`; +const uri = require('./disposableMongo.cjs').testMongoUri('keeper_recovery_test'); +function load(file,mocks={}) { + const module={exports:{}}; + const code=ts.transpileModule(fs.readFileSync(path.join(__dirname,'..',file),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020,esModuleInterop:true}}).outputText; + vm.runInNewContext(code,{module,exports:module.exports,global,console,require:n=>n in mocks?mocks[n]:require(n)}); + return module.exports; +} +const db=load('src/db.ts',{'./config':{DATABASE:uri},'./interface':{NotificationType:{RELEASE_MESSAGE:'RELEASE_MESSAGE'}}}).default; +const legacy=load('src/services/bhr.ts',{'./backupSnapshot':load('src/services/backupSnapshot.ts',{'../db':db}),'../db':db,'../interface':{},'./app':{getAppSubscriptionDetails:async()=>({level:0,isCancelled:false})},'../utils/plans':{getPlans:()=>[{level:0,name:'synthetic-plan'}]}}); +const service=load('src/services/backupSnapshot.ts',{'../db':db}); +let app,vault,appVault,label,map; +before(async()=>{ + await new Promise((resolve,reject)=>{if(mongoose.connection.readyState===1)return resolve();mongoose.connection.once('open',resolve);mongoose.connection.once('error',reject);}); + [app,vault,label,map]=[db.getAppImageModel(),db.getVaultImageModel(),db.getLabelModel(),db.getVaultMapModel()]; + appVault=db.getAppVaultImageModel(); + await Promise.all([app.init(),vault.init(),appVault.init(),label.init(),map.init()]); +}); +after(async()=>{await mongoose.connection.dropDatabase();await mongoose.disconnect();}); +async function payload(id){const snap=await service.getBackupSnapshot(id);return {appId:id,publicId:`public-${id}`,version:'2.5.16',expectedRevision:snap.revision,walletObject:{w:'encrypted-wallet'},signersObject:{},vaultObject:{},nodes:[],labels:[]};} +test('absent app repair stores all records atomically and returns a new revision',async()=>{ + const p=await payload('new'); await service.repairAppBackup(p); + const s=await service.getBackupSnapshot('new');assert.equal(s.appImage.wallets.w,'encrypted-wallet');assert.notEqual(s.revision,p.expectedRevision); +}); +test('stale revision rejects repair and preserves another device wallet',async()=>{ + const p=await payload('stale');await service.repairAppBackup(p); + p.expectedRevision=(await service.getBackupSnapshot('stale')).revision; + await app.updateOne({appId:'stale'},{$set:{'wallets.other':'other-device'}}); + await assert.rejects(service.repairAppBackup(p),service.BackupConflict); + assert.equal((await app.findOne({appId:'stale'}).lean()).wallets.other,'other-device'); +}); +test('two repairs from one revision cannot both commit',async()=>{ + const p=await payload('race');await service.repairAppBackup(p);p.expectedRevision=(await service.getBackupSnapshot('race')).revision; + const outcomes=await Promise.allSettled([service.repairAppBackup({...p,walletObject:{w:'device-A'}}),service.repairAppBackup({...p,walletObject:{w:'device-B'}})]); + assert.equal(outcomes.filter(o=>o.status==='fulfilled').length,1);assert.equal(outcomes.filter(o=>o.status==='rejected').length,1); +}); +test('same label id updates content without dropping the label',async()=>{ + const p=await payload('labels');p.labels=[{id:'label-1',content:'old-encrypted'}];await service.repairAppBackup(p); + p.expectedRevision=(await service.getBackupSnapshot('labels')).revision;p.labels[0].content='new-encrypted';await service.repairAppBackup(p); + const s=await service.getBackupSnapshot('labels');assert.equal(s.labels[0].content,'new-encrypted');assert.deepEqual(s.appImage.labels,['label-1']); +}); +test('repair cannot silently delete server-only records even with current revision',async()=>{ + const p=await payload('omission');await service.repairAppBackup(p);p.expectedRevision=(await service.getBackupSnapshot('omission')).revision;p.walletObject={}; + await assert.rejects(service.repairAppBackup(p),service.BackupConflict);assert.equal((await service.getBackupSnapshot('omission')).appImage.wallets.w,'encrypted-wallet'); +}); +test('explicit current-state repair removes absent records and orphaned vault and label data',async()=>{ + const p=await payload('current-state'); + p.signersObject={key:'encrypted-key'}; + p.vaultObject={old:{vaultId:'old',vault:'encrypted-vault',isArchived:true,signersData:[{signerId:'key',xfpHash:'hash'}]}}; + p.labels=[{id:'old-label',content:'encrypted-label'}]; + await service.repairAppBackup(p); + const before=await service.getBackupSnapshot(p.appId); + await service.repairAppBackup({...p,expectedRevision:before.revision,replaceCurrentState:true, + walletObject:{},signersObject:{},vaultObject:{},labels:[]}); + const after=await service.getBackupSnapshot(p.appId); + assert.deepEqual(after.appImage.wallets,{}); + assert.deepEqual(after.appImage.signers,{}); + assert.deepEqual(after.appImage.vaults,[]); + assert.deepEqual(after.appImage.labels,[]); + assert.equal(await vault.countDocuments({vaultId:'old'}),0); + assert.equal(await label.countDocuments({id:'old-label'}),0); + assert.equal(await map.countDocuments({vaultId:'old'}),0); +}); +test('stale current-state repair cannot remove a newer record',async()=>{ + const p=await payload('current-state-race');await service.repairAppBackup(p); + const oldRevision=(await service.getBackupSnapshot(p.appId)).revision; + await app.updateOne({appId:p.appId},{$set:{'wallets.newer':'encrypted-newer'}}); + await assert.rejects(service.repairAppBackup({...p,expectedRevision:oldRevision,replaceCurrentState:true,walletObject:{}}),service.BackupConflict); + assert.equal((await service.getBackupSnapshot(p.appId)).appImage.wallets.newer,'encrypted-newer'); +}); +test('explicit backup deletion leaves no recoverable records or orphaned vault data',async()=>{ + const p=await payload('delete-choice'); + p.signersObject={key:'encrypted-key'}; + p.vaultObject={vault:{vaultId:'vault',vault:'encrypted-vault',isArchived:false,signersData:[{signerId:'key',xfpHash:'hash'}]}}; + p.labels=[{id:'delete-label',content:'encrypted-label'}]; + await service.repairAppBackup(p); + const result=await legacy.deleteBackup(p.appId); + assert.equal(result.updated,true); + assert.equal(result.error,''); + const remaining=await service.getBackupSnapshot(p.appId); + assert.deepEqual(remaining.appImage.wallets,{}); + assert.deepEqual(remaining.appImage.signers,{}); + assert.deepEqual(remaining.appImage.vaults,[]); + assert.deepEqual(remaining.appImage.labels,[]); + assert.equal(await vault.countDocuments({vaultId:'vault'}),0); + assert.equal(await label.countDocuments({id:'delete-label'}),0); + assert.equal(await map.countDocuments({vaultId:'vault'}),0); +}); +test('failed explicit deletion rolls back earlier vault removals',async()=>{ + const p=await payload('delete-rollback'); + p.vaultObject={'rollback-vault':{vaultId:'rollback-vault',vault:'encrypted-vault',isArchived:false,signersData:[]}}; + p.labels=[{id:'rollback-label',content:'encrypted-label'}]; + await service.repairAppBackup(p); + const original=label.deleteOne; + label.deleteOne=()=>({session:async()=>{throw Error('disposable deletion failure');}}); + try { await assert.rejects(legacy.deleteBackup(p.appId)); } + finally { label.deleteOne=original; } + const remaining=await service.getBackupSnapshot(p.appId); + assert.equal(remaining.appImage.wallets.w,'encrypted-wallet'); + assert.deepEqual(remaining.appImage.vaults,['rollback-vault']); + assert.deepEqual(remaining.appImage.labels,['rollback-label']); + assert.equal(await vault.countDocuments({vaultId:'rollback-vault'}),1); + assert.equal(await label.countDocuments({id:'rollback-label'}),1); +}); +test('failure after earlier writes rolls the entire transaction back',async()=>{ + const owner=await payload('owner');owner.labels=[{id:'rollback-owned-label',content:'owned'}];await service.repairAppBackup(owner); + const p=await payload('rollback');p.vaultObject={v1:{vaultId:'v1',vault:'first-write',isArchived:false,signersData:[]}};p.labels=[{id:'rollback-owned-label',content:'wrong-owner'}]; + await assert.rejects(service.repairAppBackup(p),service.BackupConflict);assert.equal(await vault.countDocuments({vaultId:'v1'}),0);assert.equal(await appVault.countDocuments({appId:p.appId}),0);assert.equal(await app.countDocuments({appId:'rollback'}),0); +}); +test('archived metadata survives and active signer map survives archive ordering',async()=>{ + const p=await payload('archive');const signersData=[{signerId:'fixture-signer',xfpHash:'fixture-hash'}]; + p.vaultObject={active:{vaultId:'active',vault:'active-image',isArchived:false,signersData},archived:{vaultId:'archived',vault:'archived-image',isArchived:true,signersData}}; + await service.repairAppBackup(p);assert.equal((await vault.findOne({vaultId:'archived'}).lean()).isArchived,true);assert.equal((await map.findOne({signerId:'fixture-signer'}).lean()).vaultId,'active'); +}); +test('full repair removes a replaced active signer lookup',async()=>{ + const p=await payload('repair-signer-replacement'); + p.vaultObject={replacement:{vaultId:'replacement',vault:'first-image',isArchived:false,signersData:[{signerId:'old-repair-signer',xfpHash:'old-repair-hash'}]}}; + await service.repairAppBackup(p); + p.expectedRevision=(await service.getBackupSnapshot(p.appId)).revision; + p.vaultObject.replacement={...p.vaultObject.replacement,vault:'second-image',signersData:[{signerId:'new-repair-signer',xfpHash:'new-repair-hash'}]}; + await service.repairAppBackup(p); + assert.equal(await map.countDocuments({signerId:'old-repair-signer'}),0); + assert.equal((await map.findOne({signerId:'new-repair-signer'}).lean()).vaultId,'replacement'); +}); +test('legacy full vault save removes a replaced active signer lookup',async()=>{ + const p=await payload('legacy-signer-replacement');await service.repairAppBackup(p); + assert.equal((await legacy.addVaultImage(p.appId,null,'legacy-replacement',null,'first-image',[{signerId:'old-legacy-signer',xfpHash:'old-legacy-hash'}])).updated,true); + assert.equal((await legacy.addVaultImage(p.appId,null,'legacy-replacement',null,'second-image',[{signerId:'new-legacy-signer',xfpHash:'new-legacy-hash'}])).updated,true); + assert.equal(await map.countDocuments({signerId:'old-legacy-signer'}),0); + assert.equal((await map.findOne({signerId:'new-legacy-signer'}).lean()).vaultId,'legacy-replacement'); +}); +test('incomplete referenced records fail closed',async()=>{ + const p=await payload('incomplete');await service.repairAppBackup(p);await app.updateOne({appId:'incomplete'},{$set:{labels:['missing-record']}}); + await assert.rejects(service.getBackupSnapshot('incomplete'),/Incomplete backup/); +}); +test('malformed encrypted payload cannot create records',async()=>{ + const p=await payload('invalid');p.nodes=[{}];await assert.rejects(service.repairAppBackup(p),/Invalid encrypted node/);assert.equal(await app.countDocuments({appId:'invalid'}),0); +}); + +test('version-only incremental update succeeds without nodes and preserves records',async()=>{ + const p=await payload('version');await service.repairAppBackup(p); + assert.equal((await legacy.updateAppImage('version',undefined,undefined,undefined,'2.5.17')).updated,true); + const s=await service.getBackupSnapshot('version');assert.equal(s.appImage.version,'2.5.17');assert.equal(s.appImage.wallets.w,'encrypted-wallet'); +}); +test('concurrent incremental updates preserve both wallets',async()=>{ + const p=await payload('increments');await service.repairAppBackup(p); + const results=await Promise.all([legacy.updateAppImage('increments',null,{a:'device-a'}),legacy.updateAppImage('increments',null,{b:'device-b'})]); + assert.ok(results.every(r=>r.updated));const s=await service.getBackupSnapshot('increments');assert.deepEqual(s.appImage.wallets,{w:'encrypted-wallet',a:'device-a',b:'device-b'}); +}); + +test('legacy first and repeat full backups work until revisioned repair',async()=>{ + const p=await payload('legacy-full');delete p.expectedRevision; + assert.equal((await legacy.backupAllSignersAndVaults(p)).updated,true); + p.walletObject.extra='later-wallet'; + assert.equal((await legacy.backupAllSignersAndVaults(p)).updated,true); + assert.equal(!!(await service.getBackupSnapshot(p.appId)).appImage.backupRevisionRequired,false); +}); +test('legacy full backup cannot overwrite a repaired backup',async()=>{ + const p=await payload('protected');await service.repairAppBackup(p); + await assert.rejects(legacy.backupAllSignersAndVaults({...p,walletObject:{w:'stale'}}),/Update the app/); + const s=await service.getBackupSnapshot(p.appId);assert.equal(s.appImage.wallets.w,'encrypted-wallet');assert.equal(s.appImage.backupRevisionRequired,true); +}); +test('legacy omissions cannot delete server-only keys, wallets, vaults or labels',async()=>{ + for(const kind of ['walletObject','signersObject','vaultObject','labels']){ + const p=await payload(`legacy-omission-${kind}`); + p.signersObject={k:'key-image'};p.vaultObject[`v-${kind}`]={vaultId:`v-${kind}`,vault:'vault-image',signersData:[]};p.labels=[{id:`l-${kind}`,content:'label-image'}]; + await legacy.backupAllSignersAndVaults(p);const before=await service.getBackupSnapshot(p.appId); + await assert.rejects(legacy.backupAllSignersAndVaults({...p,[kind]:kind==='labels'?[]:{}}),/Backup changed/); + assert.equal((await service.getBackupSnapshot(p.appId)).revision,before.revision); + } +}); +test('legacy writer preserves archive status absent from old payloads',async()=>{ + const p=await payload('legacy-archive');p.vaultObject['legacy-archived']={vault:'image',signersData:[],vaultShellId:'fixture-shell'}; + await legacy.backupAllSignersAndVaults(p);await legacy.archiveVault('legacy-archived'); + await legacy.backupAllSignersAndVaults(p);const result=await vault.findOne({vaultId:'legacy-archived'}).lean();assert.equal(result.isArchived,true);assert.equal(result.vaultShellId,'fixture-shell'); +}); +test('legacy versus repair race never overwrites a committed repair',async()=>{ + for(let i=0;i<10;i++){ + const p=await payload(`legacy-race-${i}`);await legacy.backupAllSignersAndVaults(p); + p.expectedRevision=(await service.getBackupSnapshot(p.appId)).revision; + const [oldResult,newResult]=await Promise.allSettled([ + legacy.backupAllSignersAndVaults({...p,walletObject:{w:'old-writer'}}), + service.repairAppBackup({...p,walletObject:{w:'repaired',extra:'keep-me'}}), + ]); + const s=await service.getBackupSnapshot(p.appId); + if(newResult.status==='fulfilled'){ + assert.equal(s.appImage.wallets.w,'repaired');assert.equal(s.appImage.wallets.extra,'keep-me');assert.equal(s.appImage.backupRevisionRequired,true); + }else{ + assert.equal(oldResult.status,'fulfilled');assert.match(newResult.reason.message,/Backup changed/); + assert.equal(s.appImage.wallets.w,'old-writer'); + } + } +}); +test('repair with unchanged ciphertext still protects against an overlapping legacy replacement',async()=>{ + for(let i=0;i<10;i++){ + const p=await payload(`marker-race-${i}`);await legacy.backupAllSignersAndVaults(p); + p.expectedRevision=(await service.getBackupSnapshot(p.appId)).revision; + const [repair]=await Promise.allSettled([ + service.repairAppBackup(p), + legacy.backupAllSignersAndVaults({...p,walletObject:{w:'stale-writer'}}), + ]); + const s=await service.getBackupSnapshot(p.appId); + if(repair.status==='fulfilled'){ + assert.equal(s.appImage.wallets.w,'encrypted-wallet');assert.equal(s.appImage.backupRevisionRequired,true); + }else assert.match(repair.reason.message,/Backup changed/); + } +}); +test('label ownership collision fails without altering either backup',async()=>{ + const owner=await payload('label-owner');owner.labels=[{id:'owned-label',content:'keep-this'}];await service.repairAppBackup(owner); + const other=await payload('label-other');other.labels=[{id:'owned-label',content:'wrong-account'}]; + await assert.rejects(service.repairAppBackup(other),/Backup changed/); + assert.equal((await service.getBackupSnapshot(owner.appId)).labels[0].content,'keep-this'); + assert.equal((await service.getBackupSnapshot(other.appId)).exists,false); +}); + +test('incremental deletion acknowledges commit and leaves valid empty wallet/key maps',async()=>{ + const p=await payload('entity-delete-empty');p.signersObject={key:'encrypted-key'}; + await service.repairAppBackup(p); + const result=await legacy.deleteAppImageEntity(p.appId,['w'],['key']); + assert.equal(result.updated,true);assert.equal(result.error,''); + const stored=await app.findOne({appId:p.appId}).lean(); + assert.deepEqual(stored.wallets,{});assert.deepEqual(stored.signers,{}); + assert.equal(stored.backupRevisionRequired,true); +}); + +test('incremental deletion preserves missing-account and omitted-ID behavior',async()=>{ + const missing=await legacy.deleteAppImageEntity('entity-delete-missing',undefined,undefined); + assert.equal(missing.updated,false);assert.equal(missing.error,'No app image'); + const p=await payload('entity-delete-noop');await service.repairAppBackup(p); + const before=(await service.getBackupSnapshot(p.appId)).revision; + for(const ids of [undefined,null,[],['absent-id']]){ + assert.equal((await legacy.deleteAppImageEntity(p.appId,ids,ids)).updated,true); + assert.equal((await service.getBackupSnapshot(p.appId)).revision,before); + } +}); + +test('unsafe incremental deletion IDs cannot modify a backup',async()=>{ + const p=await payload('entity-delete-invalid');await service.repairAppBackup(p); + const before=(await service.getBackupSnapshot(p.appId)).revision; + for(const ids of [['w.nested'],['$wallet'],['__proto__'],[null],{},['']]){ + assert.equal((await legacy.deleteAppImageEntity(p.appId,ids,[])).updated,false); + assert.equal((await service.getBackupSnapshot(p.appId)).revision,before); + } +}); + +test('incremental deletion write failure reports failure and rolls back both maps',async()=>{ + const p=await payload('entity-delete-failed');p.signersObject={key:'encrypted-key'}; + await service.repairAppBackup(p); + const before=(await service.getBackupSnapshot(p.appId)).revision; + const original=app.updateOne; + app.updateOne=async function(filter,changes,options){ + const result=await original.call(this,filter,changes,options); + if(changes.$unset) throw Error('disposable deletion acknowledgement failure'); + return result; + }; + let result; + try { result=await legacy.deleteAppImageEntity(p.appId,['w'],['key']); } + finally { app.updateOne=original; } + assert.equal(result.updated,false);assert.equal(result.error,'Backup deletion failed'); + assert.equal((await service.getBackupSnapshot(p.appId)).revision,before); +}); + +function delayedEntityDeletion(){ + const original=app.updateOne; + let release,announce,paused=false,attempts=0; + const gate=new Promise(resolve=>{release=resolve;}); + const started=new Promise(resolve=>{announce=resolve;}); + app.updateOne=async function(filter,changes,options){ + if(changes.$unset){ + attempts++; + if(!paused){paused=true;announce();await gate;} + } + return original.call(this,filter,changes,options); + }; + return {started,release,attempts:()=>attempts,restore:()=>{app.updateOne=original;release();}}; +} + +test('incremental deletion cannot report success before its write completes',async()=>{ + const p=await payload('entity-delete-ack');await service.repairAppBackup(p); + const pause=delayedEntityDeletion();let complete=false; + const deletion=legacy.deleteAppImageEntity(p.appId,['w'],[]).then(result=>{complete=true;return result;}); + try { + await Promise.race([pause.started,deletion.then(()=>{throw Error('deletion returned before its write');})]); + assert.equal(complete,false); + assert.equal((await app.findOne({appId:p.appId}).lean()).wallets.w,'encrypted-wallet'); + pause.release();assert.equal((await deletion).updated,true); + assert.deepEqual((await app.findOne({appId:p.appId}).lean()).wallets,{}); + } finally {pause.restore();await deletion;} +}); + +test('delayed incremental deletion preserves a newer repair instead of replacing its map',async()=>{ + const p=await payload('entity-delete-repair-race'); + p.walletObject={w:'older-retained',remove:'delete-this','server-only':'old-backup-only'}; + p.signersObject={old:'older-key',remove:'delete-key'}; + await service.repairAppBackup(p); + const revision=(await service.getBackupSnapshot(p.appId)).revision; + const pause=delayedEntityDeletion(); + const deletion=legacy.deleteAppImageEntity(p.appId,['remove'],['remove']); + try { + await Promise.race([pause.started,deletion.then(()=>{throw Error('deletion returned before its write');})]); + await service.repairAppBackup({...p,expectedRevision:revision,replaceCurrentState:true, + walletObject:{w:'repaired-retained',remove:'delete-this',newer:'new-wallet'}, + signersObject:{old:'repaired-key',remove:'delete-key',newer:'new-key'}}); + pause.release();assert.equal((await deletion).updated,true); + const stored=await app.findOne({appId:p.appId}).lean(); + assert.deepEqual(stored.wallets,{w:'repaired-retained',newer:'new-wallet'}); + assert.deepEqual(stored.signers,{old:'repaired-key',newer:'new-key'}); + assert.equal(stored.backupRevisionRequired,true); + assert.ok(pause.attempts()>1,'the real Mongo conflict retried the deletion transaction'); + } finally {pause.restore();await deletion;} +}); + +function vaultUpdateRouteHandler(){ + const file='src/routes/routes.ts'; + const source=ts.createSourceFile(file,fs.readFileSync(path.join(__dirname,'..',file),'utf8'),ts.ScriptTarget.Latest,true); + let registration,callback; + function visit(node){ + if(ts.isCallExpression(node)&&node.expression.getText(source)==='router.post'&&node.arguments[0]?.text==='/updateVaultImage') registration=node; + ts.forEachChild(node,visit); + } + visit(source);assert.ok(registration); + vm.runInNewContext(ts.transpileModule(registration.getText(source),{compilerOptions:{target:ts.ScriptTarget.ES2020}}).outputText, + {bhr:legacy,console,router:{post:(_path,handler)=>{callback=handler;}}}); + return callback; +} + +test('existing vault update waits for persistence and returns exactly one response',async()=>{ + const p=await payload('vault-route-update'); + p.vaultObject={'route-vault':{vaultId:'route-vault',vault:'old-image',isArchived:false,signersData:[]}}; + await service.repairAppBackup(p); + const original=legacy.addVaultImage;let addCalls=0,responses=0,status,body; + legacy.addVaultImage=async()=>{addCalls++;return {updated:true};}; + const response={status(code){status=code;return this;},json(value){responses++;body=value;if(responses>1)throw Error('double response');return this;}}; + try {await vaultUpdateRouteHandler()({body:{isUpdate:true,vaultId:'route-vault',vault:'new-image'}},response);} + finally {legacy.addVaultImage=original;} + assert.equal(status,200);assert.equal(body.updated,true);assert.equal(responses,1);assert.equal(addCalls,0); + assert.equal((await vault.findOne({vaultId:'route-vault'}).lean()).vault,'new-image'); +}); + +test('missing vault update fails once without falling through to vault creation',async()=>{ + const original=legacy.addVaultImage;let addCalls=0,responses=0,status,body; + legacy.addVaultImage=async()=>{addCalls++;return {updated:true};}; + const response={status(code){status=code;return this;},json(value){responses++;body=value;return this;}}; + try {await vaultUpdateRouteHandler()({body:{isUpdate:true,vaultId:'missing-route-vault',vault:'new-image'}},response);} + finally {legacy.addVaultImage=original;} + assert.equal(status,503);assert.equal(body.updated,false);assert.equal(responses,1);assert.equal(addCalls,0); + assert.equal(await vault.countDocuments({vaultId:'missing-route-vault'}),0); +}); + +test('vault update persistence failure returns one failure response',async()=>{ + const p=await payload('vault-route-failed'); + p.vaultObject={'failed-route-vault':{vaultId:'failed-route-vault',vault:'keep-image',isArchived:false,signersData:[]}}; + await service.repairAppBackup(p); + const original=vault.collection.updateOne;let responses=0,status,body; + vault.collection.updateOne=function(...args){const callback=args[args.length-1];callback(Error('disposable vault write failure'));}; + const response={status(code){status=code;return this;},json(value){responses++;body=value;return this;}}; + try {await vaultUpdateRouteHandler()({body:{isUpdate:true,vaultId:'failed-route-vault',vault:'failed-image'}},response);} + finally {vault.collection.updateOne=original;} + assert.equal(status,503);assert.equal(body.updated,false);assert.equal(responses,1); + assert.equal((await vault.findOne({vaultId:'failed-route-vault'}).lean()).vault,'keep-image'); +}); + +const signerData = id => [{signerId:id,xfpHash:`hash-${id}`}]; +const vaultRecord = (id,key,image='encrypted-vault') => ({vaultId:id,vault:image,isArchived:false,signersData:signerData(key)}); +function interceptQuery(model,method,predicate,intercept){ + const original=model[method]; + model[method]=function(...args){ + const query=original.apply(this,args),exec=query.exec; + if(predicate(...args)) query.exec=async function(...execArgs){ + return intercept(()=>exec.apply(this,execArgs)); + }; + return query; + }; + return ()=>{model[method]=original;}; +} +function pauseQuery(model,method,predicate){ + let release,announce,paused=false,attempts=0; + const gate=new Promise(resolve=>{release=resolve;}),started=new Promise(resolve=>{announce=resolve;}); + const restore=interceptQuery(model,method,predicate,async execute=>{ + attempts++; + if(!paused){paused=true;announce();await gate;} + return execute(); + }); + return {started,release,attempts:()=>attempts,restore:()=>{restore();release();}}; +} + +test('incremental vault creation commits image, membership and maps without duplicate IDs',async()=>{ + const p=await payload('vault-add-atomic');await service.repairAppBackup(p); + for(let repeat=0;repeat<2;repeat++){ + const result=await legacy.addVaultImage(p.appId,'fixture-shell','new-vault',{m:2,n:3},'fixture-image',signerData('new-vault-key')); + assert.equal(result.updated,true); + const stored=await service.getBackupSnapshot(p.appId); + assert.deepEqual(stored.appImage.vaults,['new-vault']); + assert.equal(stored.allVaultImages[0].vault,'fixture-image'); + assert.equal((await map.findOne({signerId:'new-vault-key'}).lean()).vaultId,'new-vault'); + assert.equal(stored.appImage.backupRevisionRequired,true); + } + const request=await payload(p.appId); + await assert.rejects(legacy.backupAllSignersAndVaults(request),/Update the app/); +}); + +test('vault creation cannot acknowledge before the final signer map write commits',async()=>{ + const p=await payload('vault-add-ack');await service.repairAppBackup(p); + const pause=pauseQuery(map,'updateOne',filter=>filter.signerId==='ack-vault-key'); + const creation=legacy.addVaultImage(p.appId,null,'ack-vault',null,'fixture-image',signerData('ack-vault-key')); + try { + await Promise.race([pause.started,creation.then(()=>{throw Error('vault creation returned before its map write');})]); + assert.deepEqual((await service.getBackupSnapshot(p.appId)).appImage.vaults,[]); + assert.equal(await vault.countDocuments({vaultId:'ack-vault'}),0); + pause.release();assert.equal((await creation).updated,true); + assert.equal((await map.findOne({signerId:'ack-vault-key'}).lean()).vaultId,'ack-vault'); + } finally {pause.restore();await creation;} +}); + +test('vault creation map failure rolls back image, maps and membership',async()=>{ + const p=await payload('vault-add-failed');await service.repairAppBackup(p); + const restore=interceptQuery(map,'updateOne',filter=>filter.signerId==='failed-vault-key',async execute=>{ + await execute();throw Error('disposable map acknowledgement failure'); + }); + let result; + try {result=await legacy.addVaultImage(p.appId,null,'failed-vault',null,'fixture-image',signerData('failed-vault-key'));} + finally {restore();} + assert.equal(result.updated,false); + assert.deepEqual((await service.getBackupSnapshot(p.appId)).appImage.vaults,[]); + assert.equal(await vault.countDocuments({vaultId:'failed-vault'}),0); + assert.equal(await map.countDocuments({signerId:'failed-vault-key'}),0); +}); + +test('delayed vault creation preserves a newer repair membership and removed records',async()=>{ + const p=await payload('vault-add-repair-race');p.vaultObject.old=vaultRecord('old','old-race-key'); + await service.repairAppBackup(p);const revision=(await service.getBackupSnapshot(p.appId)).revision; + const pause=pauseQuery(app,'updateOne',(_filter,changes)=>changes.$addToSet?.vaults==='delayed-new'); + const creation=legacy.addVaultImage(p.appId,null,'delayed-new',null,'new-image',signerData('delayed-key')); + try { + await Promise.race([pause.started,creation.then(()=>{throw Error('creation did not await membership');})]); + await service.repairAppBackup({...p,expectedRevision:revision,replaceCurrentState:true, + walletObject:{w:'repaired-wallet',extra:'newer-wallet'},vaultObject:{other:vaultRecord('other','other-race-key')}}); + pause.release();assert.equal((await creation).updated,true); + const stored=await service.getBackupSnapshot(p.appId); + assert.deepEqual([...stored.appImage.vaults].sort(),['delayed-new','other']); + assert.equal(stored.appImage.wallets.extra,'newer-wallet');assert.equal(stored.appImage.wallets.w,'repaired-wallet'); + assert.equal(await vault.countDocuments({vaultId:'old'}),0); + assert.equal((await map.findOne({signerId:'delayed-key'}).lean()).vaultId,'delayed-new'); + assert.ok(pause.attempts()>1,'membership conflict retried the creation transaction'); + } finally {pause.restore();await creation;} +}); + +test('legacy archive-and-add route commits both changes and retains the active signer map',async()=>{ + const p=await payload('vault-archive-route');p.vaultObject.previous=vaultRecord('previous','shared-migration-key'); + await service.repairAppBackup(p);let responses=0,status,body; + const response={status(code){status=code;return this;},json(value){responses++;body=value;return this;}}; + await vaultUpdateRouteHandler()({body:{appID:p.appId,archiveVaultId:'previous',vaultId:'replacement',vault:'replacement-image',signersData:signerData('shared-migration-key')}},response); + assert.equal(status,200);assert.equal(body.updated,true);assert.equal(responses,1); + assert.equal((await vault.findOne({vaultId:'previous'}).lean()).isArchived,true); + assert.equal((await map.findOne({signerId:'shared-migration-key'}).lean()).vaultId,'replacement'); + assert.deepEqual([...((await service.getBackupSnapshot(p.appId)).appImage.vaults)].sort(),['previous','replacement']); +}); + +test('legacy archive-and-add failure restores the previous archive and map',async()=>{ + const p=await payload('vault-archive-add-failed');p.vaultObject.previous=vaultRecord('archive-failed-previous','archive-failed-key'); + // The record dictionary key is also its vault ID. + p.vaultObject={'archive-failed-previous':p.vaultObject.previous}; + await service.repairAppBackup(p); + const restore=interceptQuery(map,'updateOne',filter=>filter.signerId==='archive-failed-key',async execute=>{ + await execute();throw Error('disposable replacement map failure'); + }); + let result; + try {result=await legacy.addVaultImage(p.appId,null,'failed-replacement',null,'replacement-image',signerData('archive-failed-key'),null,'archive-failed-previous');} + finally {restore();} + assert.equal(result.updated,false); + assert.equal((await vault.findOne({vaultId:'archive-failed-previous'}).lean()).isArchived,false); + assert.equal((await map.findOne({signerId:'archive-failed-key'}).lean()).vaultId,'archive-failed-previous'); + assert.equal(await vault.countDocuments({vaultId:'failed-replacement'}),0); +}); + +test('standalone legacy map creation awaits and rolls back the entire map batch',async()=>{ + const p=await payload('vault-map-batch');p.vaultObject['map-batch-vault']=vaultRecord('map-batch-vault','existing-map-key'); + await service.repairAppBackup(p); + const restore=interceptQuery(map,'updateOne',filter=>filter.signerId==='second-map-key',async execute=>{ + await execute();throw Error('disposable second map failure'); + }); + try {await assert.rejects(legacy.createVaultMap([...signerData('first-map-key'),...signerData('second-map-key')],'map-batch-vault'));} + finally {restore();} + assert.equal(await map.countDocuments({signerId:{$in:['first-map-key','second-map-key']}}),0); + assert.equal((await map.findOne({signerId:'existing-map-key'}).lean()).vaultId,'map-batch-vault'); +}); + +test('standalone archive cannot acknowledge before map removal and rolls back failure',async()=>{ + const p=await payload('vault-archive-ack');p.vaultObject['archive-ack-vault']=vaultRecord('archive-ack-vault','archive-ack-key'); + await service.repairAppBackup(p); + const pause=pauseQuery(map,'deleteMany',filter=>filter.vaultId==='archive-ack-vault'); + const archiving=legacy.archiveVault('archive-ack-vault'); + try { + await Promise.race([pause.started,archiving.then(()=>{throw Error('archive acknowledged before map deletion');})]); + assert.equal((await vault.findOne({vaultId:'archive-ack-vault'}).lean()).isArchived,false); + pause.release();assert.equal(await archiving,true); + } finally {pause.restore();await archiving;} + assert.equal((await vault.findOne({vaultId:'archive-ack-vault'}).lean()).isArchived,true); + assert.equal(await map.countDocuments({signerId:'archive-ack-key'}),0); + const second=await payload('vault-archive-failed');second.vaultObject['archive-failed-vault']=vaultRecord('archive-failed-vault','archive-rollback-key'); + await service.repairAppBackup(second); + const restore=interceptQuery(map,'deleteMany',filter=>filter.vaultId==='archive-failed-vault',async execute=>{ + await execute();throw Error('disposable archive cleanup failure'); + }); + try {assert.equal(await legacy.archiveVault('archive-failed-vault'),false);} + finally {restore();} + assert.equal((await vault.findOne({vaultId:'archive-failed-vault'}).lean()).isArchived,false); + assert.equal(await map.countDocuments({signerId:'archive-rollback-key'}),1); +}); + +test('vault deletion atomically clears last membership, image and signer lookup',async()=>{ + const p=await payload('vault-delete-atomic');p.vaultObject['delete-atomic-vault']=vaultRecord('delete-atomic-vault','delete-atomic-key'); + await service.repairAppBackup(p); + assert.equal((await legacy.deleteVaults(p.appId,['delete-atomic-vault'])).updated,true); + const stored=await service.getBackupSnapshot(p.appId); + assert.deepEqual(stored.appImage.vaults,[]);assert.deepEqual(stored.allVaultImages,[]); + assert.equal(await map.countDocuments({signerId:'delete-atomic-key'}),0); + assert.equal(stored.appImage.backupRevisionRequired,true); +}); + +test('vault deletion failure rolls back earlier map cleanup and image deletion',async()=>{ + const p=await payload('vault-delete-failed');p.vaultObject['delete-failed-vault']=vaultRecord('delete-failed-vault','delete-failed-key'); + await service.repairAppBackup(p);const before=(await service.getBackupSnapshot(p.appId)).revision; + const restore=interceptQuery(appVault,'deleteMany',filter=>filter.appId===p.appId,async execute=>{ + await execute();throw Error('disposable vault deletion failure'); + }); + try {assert.equal((await legacy.deleteVaults(p.appId,['delete-failed-vault'])).updated,false);} + finally {restore();} + assert.equal((await service.getBackupSnapshot(p.appId)).revision,before); + assert.equal(await map.countDocuments({signerId:'delete-failed-key'}),1); +}); + +test('delayed vault deletion preserves newer repaired membership and vault contents',async()=>{ + const p=await payload('vault-delete-repair-race'); + p.vaultObject={remove:vaultRecord('delete-race-remove','delete-race-remove-key'),keep:vaultRecord('delete-race-keep','delete-race-keep-key')}; + p.vaultObject={'delete-race-remove':p.vaultObject.remove,'delete-race-keep':p.vaultObject.keep}; + await service.repairAppBackup(p);const revision=(await service.getBackupSnapshot(p.appId)).revision; + const pause=pauseQuery(vault,'updateOne',(filter,update)=>filter.vaultId==='delete-race-remove'&&update.$inc?.__v===1); + const deletion=legacy.deleteVaults(p.appId,['delete-race-remove']); + try { + await Promise.race([pause.started,deletion.then(()=>{throw Error('vault deletion acknowledged before cleanup');})]); + await service.repairAppBackup({...p,expectedRevision:revision,replaceCurrentState:true,vaultObject:{ + ...p.vaultObject,'delete-race-keep':vaultRecord('delete-race-keep','delete-race-keep-key','repaired-image'), + 'delete-race-new':vaultRecord('delete-race-new','delete-race-new-key')}}); + pause.release();assert.equal((await deletion).updated,true); + const stored=await service.getBackupSnapshot(p.appId); + assert.deepEqual([...stored.appImage.vaults].sort(),['delete-race-keep','delete-race-new']); + assert.equal(stored.allVaultImages.find(v=>v.vaultId==='delete-race-keep').vault,'repaired-image'); + assert.equal(await map.countDocuments({signerId:'delete-race-remove-key'}),0); + assert.equal(await map.countDocuments({signerId:'delete-race-new-key'}),1); + assert.ok(pause.attempts()>1,'membership conflict retried vault deletion'); + } finally {pause.restore();await deletion;} +}); + +test('incremental vault writers preserve independent peer records and missing-record behavior',async()=>{ + const p=await payload('vault-owner-audit');p.vaultObject['owned-vault']=vaultRecord('owned-vault','owned-key'); + await service.repairAppBackup(p);const other=await payload('vault-other-owner');await service.repairAppBackup(other); + assert.equal((await legacy.addVaultImage(other.appId,null,'owned-vault',null,'other-image',signerData('owned-key'))).updated,true); + assert.equal((await vault.findOne({vaultId:'owned-vault'}).lean()).vault,'encrypted-vault'); + assert.equal((await service.getBackupSnapshot(other.appId)).allVaultImages[0].vault,'other-image'); + assert.equal((await legacy.deleteVaults(other.appId,['owned-vault'])).updated,true); + assert.equal(await vault.countDocuments({vaultId:'owned-vault'}),1); + assert.equal((await legacy.deleteVaults('missing-vault-owner',[])).updated,false); + assert.equal(await legacy.archiveVault('missing-archive-vault'),false); + await assert.rejects(legacy.createVaultMap(signerData('orphan-key'),'missing-map-vault')); + assert.equal(await map.countDocuments({signerId:'orphan-key'}),0); +}); + +const plain = value => JSON.parse(JSON.stringify(value)); +// Synthetic seed-derived authenticated ciphertext proves record isolation and +// decryption independently of Mongo metadata. No funded/customer keys are used. +function encryptedPeer(seed, value) { + const key=crypto.createHash('sha256').update(seed).digest(),iv=crypto.randomBytes(12); + const cipher=crypto.createCipheriv('aes-256-gcm',key,iv); + const ciphertext=Buffer.concat([cipher.update(JSON.stringify(value),'utf8'),cipher.final()]); + return Buffer.concat([iv,cipher.getAuthTag(),ciphertext]).toString('base64'); +} +function decryptedPeer(seed, image) { + const bytes=Buffer.from(image,'base64'),key=crypto.createHash('sha256').update(seed).digest(); + const decipher=crypto.createDecipheriv('aes-256-gcm',key,bytes.subarray(0,12)); + decipher.setAuthTag(bytes.subarray(12,28)); + return JSON.parse(Buffer.concat([decipher.update(bytes.subarray(28)),decipher.final()]).toString('utf8')); +} +async function collaborativePeers(id) { + const first=await payload(`${id}-first`),second=await payload(`${id}-second`); + await service.repairAppBackup(first);await service.repairAppBackup(second); + const vaultId=crypto.createHash('sha256').update(`${id}-same-keys-and-policy`).digest('hex').slice(-8); + const signer=signerData(`${id}-shared-key`),seeds=['synthetic-first-seed','synthetic-second-seed']; + const data=[{id:vaultId,participant:'first',nextFreeAddressIndex:4},{id:vaultId,participant:'second',nextFreeAddressIndex:9}]; + const images=data.map((value,i)=>encryptedPeer(seeds[i],value)); + assert.equal((await legacy.addVaultImage(first.appId,null,vaultId,null,images[0],signer)).updated,true); + assert.equal((await legacy.addVaultImage(second.appId,null,vaultId,null,images[1],signer)).updated,true); + return {first,second,vaultId,signer,seeds,data,images}; +} + +test('archiving or unlinking one participant prunes only its unique signer lookups',async()=>{ + for (const operation of ['archive','unlink']) { + const p=await collaborativePeers(`scoped-signers-${operation}`); + const ownSigner=signerData(`scoped-${operation}-only-key`)[0]; + assert.equal((await legacy.addVaultImage(p.first.appId,null,p.vaultId,null,p.images[0],[ownSigner])).updated,true); + assert.equal((await map.findOne({signerId:p.signer[0].signerId}).lean()).vaultId,p.vaultId,'peer signer remains mapped'); + assert.equal((await map.findOne({signerId:ownSigner.signerId}).lean()).vaultId,p.vaultId); + if (operation==='archive') assert.notEqual(await legacy.updateVault(p.vaultId,p.images[0],p.first.appId,true),false); + else assert.equal((await legacy.deleteVaults(p.first.appId,[p.vaultId])).updated,true); + assert.equal(await map.countDocuments({signerId:ownSigner.signerId}),0); + assert.equal((await map.findOne({signerId:p.signer[0].signerId}).lean()).vaultId,p.vaultId); + } +}); + +test('second collaborative participant stores and restores independently encrypted canonical wallet',async()=>{ + const p=await collaborativePeers('scoped-collaborative'); + for(const [index,account] of [p.first,p.second].entries()){ + const snapshot=await service.getBackupSnapshot(account.appId); + assert.deepEqual(snapshot.appImage.vaults,[p.vaultId]); + assert.deepEqual(plain(snapshot.unavailableVaultIds),[]); + assert.equal(snapshot.allVaultImages[0].vault,p.images[index]); + assert.deepEqual(decryptedPeer(p.seeds[index],snapshot.allVaultImages[0].vault),p.data[index]); + assert.throws(()=>decryptedPeer(p.seeds[1-index],snapshot.allVaultImages[0].vault)); + assert.equal((await service.getAppBackupVaultImages(account.appId,[p.vaultId]))[0].vault,p.images[index]); + const generic=await legacy.getAppImage(account.appId,'2.5.16'); + assert.equal(generic.allVaultImages[0].vault,p.images[index]); + assert.deepEqual(decryptedPeer(p.seeds[index],generic.vaultImage.vault),p.data[index]); + } + assert.equal(await appVault.countDocuments({vaultId:p.vaultId}),2); + assert.equal((await vault.findOne({vaultId:p.vaultId}).lean()).vault,p.images[0]); + await assert.rejects(appVault.create({appId:p.first.appId,vaultId:p.vaultId,vault:'duplicate',signerIds:[]}),error=>error.code===11000); + const before=(await service.getBackupSnapshot(p.first.appId)).revision; + assert.notEqual(await legacy.updateVault(p.vaultId,encryptedPeer(p.seeds[1],{...p.data[1],nextFreeAddressIndex:10}),p.second.appId,false),false); + assert.equal((await service.getBackupSnapshot(p.first.appId)).revision,before); +}); + +test('current and compatible update route preserve scoped ownership and forward archive metadata',async()=>{ + const p=await collaborativePeers('scoped-update-route'); + const handler=vaultUpdateRouteHandler();let status,body; + const response={status(code){status=code;return this;},json(value){body=value;return this;}}; + await handler({body:{appId:p.second.appId,isUpdate:true,vaultId:p.vaultId,vault:p.images[1],isArchived:true}},response); + assert.equal(status,200);assert.equal(body.updated,true); + assert.equal((await service.getBackupSnapshot(p.second.appId)).allVaultImages[0].isArchived,true); + assert.equal((await service.getBackupSnapshot(p.first.appId)).allVaultImages[0].isArchived,false); + assert.equal(await map.countDocuments({vaultId:p.vaultId}),1,'active peer lookup survives archive'); + // Older shared-wallet updates cannot identify the participant; reject safely. + await handler({body:{isUpdate:true,vaultId:p.vaultId,vault:p.images[0]}},response); + assert.equal(status,503);assert.equal(body.updated,false); + assert.equal((await service.getBackupSnapshot(p.first.appId)).allVaultImages[0].vault,p.images[0]); + assert.equal((await service.getBackupSnapshot(p.second.appId)).allVaultImages[0].vault,p.images[1]); + assert.equal((await legacy.addVaultImage(p.second.appId,null,p.vaultId,null,p.images[1],p.signer)).updated,true); + assert.equal((await service.getBackupSnapshot(p.second.appId)).allVaultImages[0].isArchived,true,'missing old-client flag preserves archive state'); + await handler({body:{appId:p.second.appId,isUpdate:true,vaultId:p.vaultId,vault:p.images[1],isArchived:'false'}},response); + assert.equal(status,503);assert.equal(body.updated,false); +}); + +test('full peer repair and each deletion path preserve another participant recovery data',async()=>{ + for(const operation of ['incremental','current-state','delete-backup']){ + const p=await collaborativePeers(`shared-removal-${operation}`); + const before=await service.getBackupSnapshot(p.second.appId); + const repaired=encryptedPeer(p.seeds[1],{...p.data[1],nextFreeAddressIndex:12}); + await service.repairAppBackup({...p.second,expectedRevision:before.revision,vaultObject:{[p.vaultId]:vaultRecord(p.vaultId,p.signer[0].signerId,repaired)}}); + if(operation==='incremental') assert.equal((await legacy.deleteVaults(p.first.appId,[p.vaultId])).updated,true); + if(operation==='current-state') await service.repairAppBackup({...p.first,expectedRevision:(await service.getBackupSnapshot(p.first.appId)).revision,replaceCurrentState:true,vaultObject:{}}); + if(operation==='delete-backup') assert.equal((await legacy.deleteBackup(p.first.appId)).updated,true); + assert.deepEqual((await service.getBackupSnapshot(p.first.appId)).appImage.vaults,[]); + assert.equal(await appVault.countDocuments({appId:p.first.appId,vaultId:p.vaultId}),0); + const survivor=await service.getBackupSnapshot(p.second.appId); + assert.deepEqual(decryptedPeer(p.seeds[1],survivor.allVaultImages[0].vault),{...p.data[1],nextFreeAddressIndex:12}); + assert.equal(await vault.countDocuments({vaultId:p.vaultId}),1); + assert.equal(await map.countDocuments({vaultId:p.vaultId}),1); + assert.equal((await legacy.deleteVaults(p.second.appId,[p.vaultId])).updated,true); + assert.equal(await vault.countDocuments({vaultId:p.vaultId}),0); + assert.equal(await appVault.countDocuments({vaultId:p.vaultId}),0); + assert.equal(await map.countDocuments({vaultId:p.vaultId}),0); + } +}); + +test('concurrent final participant unlinks retry and leave no orphan global or scoped backup',async()=>{ + const p=await collaborativePeers('shared-final-unlink'); + const pause=pauseQuery(vault,'updateOne',(filter,update)=>filter.vaultId===p.vaultId&&update.$inc?.__v===1); + const firstDeletion=legacy.deleteVaults(p.first.appId,[p.vaultId]); + try { + await Promise.race([pause.started,firstDeletion.then(()=>{throw Error('first unlink completed before race gate');})]); + assert.equal((await legacy.deleteVaults(p.second.appId,[p.vaultId])).updated,true); + pause.release();assert.equal((await firstDeletion).updated,true); + assert.equal(await vault.countDocuments({vaultId:p.vaultId}),0); + assert.equal(await appVault.countDocuments({vaultId:p.vaultId}),0); + assert.equal(await map.countDocuments({vaultId:p.vaultId}),0); + assert.deepEqual((await app.findOne({appId:p.first.appId}).lean()).vaults,[]); + assert.deepEqual((await app.findOne({appId:p.second.appId}).lean()).vaults,[]); + } finally {pause.restore();await firstDeletion;} +}); + +test('identical legacy-owner relink invalidates concurrent final peer cleanup',async()=>{ + const p=await collaborativePeers('identical-owner-relink'); + assert.equal((await legacy.deleteVaults(p.first.appId,[p.vaultId])).updated,true); + const pause=pauseQuery(vault,'updateOne',(filter,update)=>filter.vaultId===p.vaultId&&update.$inc?.__v===1); + const lastDeletion=legacy.deleteVaults(p.second.appId,[p.vaultId]); + try { + await Promise.race([pause.started,lastDeletion.then(()=>{throw Error('unlink completed before relink gate');})]); + assert.equal((await legacy.addVaultImage(p.first.appId,null,p.vaultId,null,p.images[0],p.signer)).updated,true); + pause.release();assert.equal((await lastDeletion).updated,true); + assert.equal((await service.getBackupSnapshot(p.first.appId)).allVaultImages[0].vault,p.images[0]); + assert.equal(await vault.countDocuments({vaultId:p.vaultId}),1); + assert.equal(await map.countDocuments({vaultId:p.vaultId}),1); + assert.deepEqual((await service.getBackupSnapshot(p.second.appId)).appImage.vaults,[]); + } finally {pause.restore();await lastDeletion;} +}); + +test('standalone legacy map batch cannot commit lookup after its vault is deleted',async()=>{ + const p=await payload('map-delete-race');p.vaultObject['map-race-vault']=vaultRecord('map-race-vault','map-race-old-key');await service.repairAppBackup(p); + const pause=pauseQuery(vault,'updateOne',(filter,update)=>filter.vaultId==='map-race-vault'&&update.$inc?.__v===1); + const creation=legacy.createVaultMap(signerData('map-race-new-key'),'map-race-vault'); + const outcome=creation.then(()=>null,error=>error); + try { + await Promise.race([pause.started,outcome.then(()=>{throw Error('map operation completed before race gate');})]); + assert.equal((await legacy.deleteVaults(p.appId,['map-race-vault'])).updated,true); + pause.release();assert.match((await outcome).message,/No vault image/); + assert.equal(await map.countDocuments({signerId:'map-race-new-key'}),0); + } finally {pause.restore();await outcome;} +}); + +test('surviving same-owner legacy image is readable without creating a scoped copy',async()=>{ + const p=await payload('legacy-owned-fallback');await service.repairAppBackup(p); + await app.updateOne({appId:p.appId},{$set:{vaults:['legacy-owned-vault']}}); + await vault.create({appId:p.appId,vaultId:'legacy-owned-vault',vault:'own-legacy-ciphertext',signerIds:[],isArchived:true}); + const result=await service.getBackupSnapshot(p.appId); + assert.equal(result.allVaultImages[0].vault,'own-legacy-ciphertext'); + assert.deepEqual(plain(result.unavailableVaultIds),[]); + assert.equal((await service.getAppBackupVaultImages(p.appId,['legacy-owned-vault']))[0].isArchived,true); + assert.equal(await appVault.countDocuments({appId:p.appId}),0,'read-only fallback does not migrate records'); +}); + +test('overwritten or absent legacy images report exact unavailable IDs without exposing peer ciphertext',async()=>{ + const p=await payload('legacy-lost-peer');await legacy.backupAllSignersAndVaults(p); + await app.updateOne({appId:p.appId},{$set:{vaults:['legacy-overwritten','legacy-absent']}}); + await vault.create({appId:'foreign-peer',vaultId:'legacy-overwritten',vault:'foreign-ciphertext',signerIds:[]}); + const snapshot=await service.getBackupSnapshot(p.appId); + assert.deepEqual(snapshot.appImage.vaults,['legacy-overwritten','legacy-absent']); + assert.deepEqual(plain(snapshot.unavailableVaultIds),['legacy-absent','legacy-overwritten']); + assert.deepEqual(snapshot.allVaultImages,[]); + assert.ok(!JSON.stringify(snapshot).includes('foreign-ciphertext')); + await assert.rejects(service.getAppBackupVaultImages(p.appId,snapshot.appImage.vaults),/Incomplete backup/); + await assert.rejects(legacy.getAppImage(p.appId,'2.5.16'),/unavailable/); + await assert.rejects(legacy.backupAllSignersAndVaults({...p,vaultObject:{'legacy-overwritten':vaultRecord('legacy-overwritten','lost-key','local-ciphertext'),'legacy-absent':vaultRecord('legacy-absent','missing-key')}}),/Backup changed/); + assert.equal(await appVault.countDocuments({appId:p.appId}),0); +}); + +test('explicit local resubmission restores unavailable own scope and keeps surviving peer ciphertext',async()=>{ + const p=await payload('legacy-explicit-resubmit');await service.repairAppBackup(p); + await app.updateOne({appId:p.appId},{$set:{vaults:['legacy-resubmit-vault']}}); + await vault.create({appId:'surviving-peer',vaultId:'legacy-resubmit-vault',vault:'surviving-peer-ciphertext',signerIds:[]}); + const unavailable=await service.getBackupSnapshot(p.appId),local=encryptedPeer('synthetic-resubmit-seed',{id:'legacy-resubmit-vault',nextFreeAddressIndex:7}); + const request={...p,expectedRevision:unavailable.revision,replaceCurrentState:true,vaultObject:{'legacy-resubmit-vault':vaultRecord('legacy-resubmit-vault','resubmit-key',local)}}; + await app.updateOne({appId:p.appId},{$set:{'wallets.newer':'newer-wallet'}}); + await assert.rejects(service.repairAppBackup(request),service.BackupConflict); + assert.equal(await appVault.countDocuments({appId:p.appId}),0); + request.expectedRevision=(await service.getBackupSnapshot(p.appId)).revision; + request.walletObject={...request.walletObject,newer:'newer-wallet'}; + await service.repairAppBackup(request); + const restored=await service.getBackupSnapshot(p.appId); + assert.deepEqual(plain(restored.unavailableVaultIds),[]); + assert.notEqual(restored.revision,unavailable.revision); + assert.deepEqual(decryptedPeer('synthetic-resubmit-seed',restored.allVaultImages[0].vault),{id:'legacy-resubmit-vault',nextFreeAddressIndex:7}); + assert.equal((await vault.findOne({vaultId:'legacy-resubmit-vault'}).lean()).vault,'surviving-peer-ciphertext'); +}); + +test('scoped initialization failure is safe, retryable, and successful initialization is cached',async()=>{ + let attempts=0,indexCalls=0; + const model={find:(...args)=>appVault.find(...args),init:async()=>{attempts++;throw Error('disposable index initialization failure');},collection:{createIndex:async(...args)=>{indexCalls++;return appVault.collection.createIndex(...args);}}}; + const isolated=load('src/services/backupSnapshot.ts',{'../db':{getAppVaultImageModel:()=>model,getAppImageModel:()=>app,getVaultImageModel:()=>vault,getLabelModel:()=>label}}); + const count=await app.countDocuments(); + await assert.rejects(isolated.getBackupSnapshot('initialization-fixture'),/initialization failure/); + assert.equal(await app.countDocuments(),count); + await isolated.getBackupSnapshot('initialization-fixture');await isolated.getBackupSnapshot('initialization-fixture'); + assert.equal(attempts,1);assert.equal(indexCalls,1); +}); + +test('pending scoped initialization has a bounded failure before any transaction or record mutation',async()=>{ + let firstDeadline=true,initCalls=0,indexCalls=0; + const model={find:(...args)=>appVault.find(...args),init:()=>{initCalls++;return new Promise(()=>{});},collection:{createIndex:async(...args)=>{indexCalls++;return appVault.collection.createIndex(...args);}}}; + const isolated=load('src/services/backupSnapshot.ts',{'../db':{getAppVaultImageModel:()=>model,getAppImageModel:()=>app,getVaultImageModel:()=>vault,getLabelModel:()=>label},timers:{setTimeout:(callback,milliseconds)=>{if(firstDeadline){firstDeadline=false;queueMicrotask(callback);return 0;}return setTimeout(callback,milliseconds);},clearTimeout}}); + const count=await app.countDocuments(); + await assert.rejects(isolated.getBackupSnapshot('initialization-deadline'),/initialization unavailable/); + assert.equal(await app.countDocuments(),count); + await isolated.getBackupSnapshot('initialization-deadline');await isolated.getBackupSnapshot('initialization-deadline'); + assert.equal(initCalls,1);assert.equal(indexCalls,1,'timed-out init retries required index and caches success'); +}); + +test('required compound unique index is explicitly ensured even when model initialization creates none',async()=>{ + let indexCalls=0; + const model={find:(...args)=>appVault.find(...args),init:async()=>undefined,collection:{createIndex:async(fields,options)=>{ + indexCalls++;assert.deepEqual(plain(fields),{appId:1,vaultId:1});assert.equal(options.unique,true);assert.equal(options.maxTimeMS,15000); + return appVault.collection.createIndex(fields,options); + }}}; + const isolated=load('src/services/backupSnapshot.ts',{'../db':{getAppVaultImageModel:()=>model,getAppImageModel:()=>app,getVaultImageModel:()=>vault,getLabelModel:()=>label}}); + await isolated.getBackupSnapshot('initialization-without-auto-index');await isolated.getBackupSnapshot('initialization-without-auto-index'); + assert.equal(indexCalls,1); +}); + +test('older no-account update succeeds only for one matching legacy owner',async()=>{ + const p=await payload('legacy-single-owner-update');p.vaultObject['legacy-single-owner-vault']=vaultRecord('legacy-single-owner-vault','legacy-single-key','old-ciphertext'); + await service.repairAppBackup(p); + assert.notEqual(await legacy.updateVault('legacy-single-owner-vault','updated-ciphertext'),false); + assert.equal((await service.getBackupSnapshot(p.appId)).allVaultImages[0].vault,'updated-ciphertext'); + const other=await payload('legacy-ambiguous-update');await service.repairAppBackup(other); + assert.equal((await legacy.addVaultImage(other.appId,null,'legacy-single-owner-vault',null,'other-ciphertext',signerData('legacy-single-key'))).updated,true); + assert.equal(await legacy.updateVault('legacy-single-owner-vault','unknown-participant-ciphertext'),false); + assert.equal((await service.getBackupSnapshot(p.appId)).allVaultImages[0].vault,'updated-ciphertext'); + assert.equal((await service.getBackupSnapshot(other.appId)).allVaultImages[0].vault,'other-ciphertext'); +}); + +test('duplicate legacy vault and label references remain readable and normalize only on explicit write',async()=>{ + const p=await payload('legacy-duplicate-refs'); + const image=encryptedPeer('synthetic-duplicate-seed',{id:'legacy-duplicate-vault',nextFreeAddressIndex:3}); + p.vaultObject['legacy-duplicate-vault']=vaultRecord('legacy-duplicate-vault','duplicate-key',image); + p.labels=[{id:'legacy-duplicate-label',content:encryptedPeer('synthetic-duplicate-seed',{name:'synthetic-label'})}]; + await service.repairAppBackup(p); + await app.updateOne({appId:p.appId},{$set:{vaults:['legacy-duplicate-vault','legacy-duplicate-vault'],labels:['legacy-duplicate-label','legacy-duplicate-label']}}); + const snapshot=await service.getBackupSnapshot(p.appId),restored=await legacy.getAppImage(p.appId,'2.5.16'); + assert.deepEqual(snapshot.appImage.vaults,['legacy-duplicate-vault']); + assert.deepEqual(snapshot.appImage.labels,['legacy-duplicate-label']); + assert.equal(snapshot.allVaultImages.length,1);assert.equal(snapshot.labels.length,1); + assert.deepEqual(decryptedPeer('synthetic-duplicate-seed',restored.allVaultImages[0].vault),{id:'legacy-duplicate-vault',nextFreeAddressIndex:3}); + assert.deepEqual(decryptedPeer('synthetic-duplicate-seed',restored.labels[0].content),{name:'synthetic-label'}); + assert.deepEqual((await app.findOne({appId:p.appId}).lean()).vaults,['legacy-duplicate-vault','legacy-duplicate-vault']); + assert.deepEqual((await app.findOne({appId:p.appId}).lean()).labels,['legacy-duplicate-label','legacy-duplicate-label']); + await service.repairAppBackup({...p,expectedRevision:snapshot.revision}); + assert.deepEqual((await app.findOne({appId:p.appId}).lean()).vaults,['legacy-duplicate-vault']); + assert.deepEqual((await app.findOne({appId:p.appId}).lean()).labels,['legacy-duplicate-label']); +}); + +test('generic recovery reads one coherent snapshot during repair or deletion',async()=>{ + for(const mutation of ['repair','delete']){ + const p=await payload(`coherent-reader-${mutation}`),id=`coherent-vault-${mutation}`; + p.vaultObject[id]=vaultRecord(id,`coherent-key-${mutation}`,'old-vault-ciphertext'); + p.labels=[{id:`coherent-label-${mutation}`,content:'old-label-ciphertext'}]; + await service.repairAppBackup(p);const before=await service.getBackupSnapshot(p.appId); + const pause=pauseQuery(appVault,'find',filter=>filter.appId===p.appId); + const reading=legacy.getAppImage(p.appId,'2.5.16'); + try { + await Promise.race([pause.started,reading.then(()=>{throw Error('recovery read completed before race gate');})]); + if(mutation==='repair') await service.repairAppBackup({...p,expectedRevision:before.revision,walletObject:{w:'new-wallet-ciphertext'},vaultObject:{[id]:vaultRecord(id,`coherent-key-${mutation}`,'new-vault-ciphertext')},labels:[{id:`coherent-label-${mutation}`,content:'new-label-ciphertext'}]}); + else await legacy.deleteBackup(p.appId); + pause.release();const image=await reading; + assert.equal(image.appImage.wallets.w,'encrypted-wallet'); + assert.deepEqual(image.appImage.vaults,[id]); + assert.equal(image.allVaultImages[0].vault,'old-vault-ciphertext'); + assert.equal(image.labels[0].content,'old-label-ciphertext'); + const after=await legacy.getAppImage(p.appId,'2.5.16'); + if(mutation==='repair'){ + assert.equal(after.appImage.wallets.w,'new-wallet-ciphertext'); + assert.equal(after.allVaultImages[0].vault,'new-vault-ciphertext'); + assert.equal(after.labels[0].content,'new-label-ciphertext'); + }else{ + assert.deepEqual(after.appImage.vaults,[]);assert.equal(after.allVaultImages.length,0);assert.equal(after.labels.length,0); + } + } finally {pause.restore();await reading;} + } + const absent=await legacy.getAppImage('coherent-absent','2.5.16'); + assert.equal(absent.appImage.version,'2.5.16');assert.deepEqual(plain(absent.appImage.vaults),[]); +}); + +test('runtime operator-object or absent account inputs are rejected before any backup query',async()=>{ + const p=await payload('operator-input-seeded-account');p.vaultObject['operator-input-vault']=vaultRecord('operator-input-vault','operator-input-key','private-synthetic-ciphertext'); + await service.repairAppBackup(p); + const original=app.findOne;let queries=0; + app.findOne=function(...args){queries++;return original.apply(this,args);}; + try { + for(const value of [{$ne:null},{$in:[p.appId]},undefined,null,'',[]]){ + await assert.rejects(service.getBackupSnapshot(value),/Invalid backup account/); + await assert.rejects(service.getAppBackupVaultImages(value,['operator-input-vault']),/Invalid backup account/); + await assert.rejects(legacy.getAppImage(value,'2.5.16'),/Invalid backup account/); + } + assert.equal(queries,0); + } finally {app.findOne=original;} + assert.equal((await service.getBackupSnapshot(p.appId)).allVaultImages[0].vault,'private-synthetic-ciphertext'); +}); + +test('current archive and reinstate route rebuilds an unshared active signer lookup',async()=>{ + const p=await payload('archive-reinstate'),id='archive-reinstate-vault',signers=signerData('archive-reinstate-key'); + p.vaultObject[id]=vaultRecord(id,signers[0].signerId);await service.repairAppBackup(p); + const handler=vaultUpdateRouteHandler();let status,body; + const response={status(code){status=code;return this;},json(value){body=value;return this;}}; + for(const isArchived of [true,false]){ + await handler({body:{appId:p.appId,isUpdate:true,vaultId:id,vault:'encrypted-vault',isArchived,signersData:signers}},response); + assert.equal(status,200);assert.equal(body.updated,true); + assert.equal((await service.getBackupSnapshot(p.appId)).allVaultImages[0].isArchived,isArchived); + assert.equal(await map.countDocuments({vaultId:id}),isArchived?0:1); + } + assert.equal((await map.findOne({signerId:signers[0].signerId}).lean()).xfpHash,signers[0].xfpHash); +}); + +test('minimized empty legacy maps return valid empty shapes without dropping unavailable references',async()=>{ + const id='minimized-empty-account',vaultId='minimized-unavailable-vault'; + await app.create({appId:id,publicId:`public-${id}`,version:'2.5.16',wallets:{},signers:{},nodes:[],vaults:[vaultId],labels:[]}); + assert.equal((await app.findOne({appId:id}).lean()).wallets,undefined); + assert.equal((await app.findOne({appId:id}).lean()).signers,undefined); + const snapshot=await service.getBackupSnapshot(id); + assert.deepEqual(plain(snapshot.appImage.wallets),{});assert.deepEqual(plain(snapshot.appImage.signers),{}); + assert.deepEqual(plain(snapshot.appImage.nodes),[]);assert.deepEqual(snapshot.appImage.vaults,[vaultId]);assert.deepEqual(plain(snapshot.appImage.labels),[]); + assert.deepEqual(plain(snapshot.unavailableVaultIds),[vaultId]);assert.deepEqual(snapshot.allVaultImages,[]); + await assert.rejects(legacy.getAppImage(id,'2.5.16'),/unavailable/); + assert.equal((await app.findOne({appId:id}).lean()).wallets,undefined,'normalization remains read-only'); +}); + + +test('delete backup retains a generation that rejects the original absent token',async()=>{ + const p=await payload('generation-delete');await service.repairAppBackup(p); + await service.deleteAppBackup(p.appId); + const cleared=await service.getBackupSnapshot(p.appId); + assert.notEqual(cleared.revision,p.expectedRevision); + await assert.rejects(service.repairAppBackup(p),service.BackupConflict); + assert.deepEqual(plain(cleared.appImage.wallets),{}); +}); + +test('revisioned A to B to A content never reuses an earlier generation',async()=>{ + const p=await payload('generation-repair');await service.repairAppBackup(p); + const first=await service.getBackupSnapshot(p.appId); + await service.repairAppBackup({...p,expectedRevision:first.revision,walletObject:{w:'encrypted-B'}}); + const second=await service.getBackupSnapshot(p.appId); + await service.repairAppBackup({...p,expectedRevision:second.revision}); + const repeated=await service.getBackupSnapshot(p.appId); + assert.deepEqual(plain(repeated.appImage.wallets),plain(first.appImage.wallets)); + assert.notEqual(repeated.revision,first.revision); + await assert.rejects(service.repairAppBackup({...p,expectedRevision:first.revision,walletObject:{w:'stale-ciphertext'}}),service.BackupConflict); +}); + +test('incremental A to B to A writes advance the same transactional generation',async()=>{ + const p=await payload('generation-incremental');await service.repairAppBackup(p); + const first=await service.getBackupSnapshot(p.appId); + for(const value of ['encrypted-B','encrypted-wallet']) assert.equal((await legacy.updateAppImage(p.appId,null,{w:value})).updated,true); + const repeated=await service.getBackupSnapshot(p.appId); + assert.deepEqual(plain(repeated.appImage.wallets),plain(first.appImage.wallets));assert.notEqual(repeated.revision,first.revision); + await assert.rejects(service.repairAppBackup({...p,expectedRevision:first.revision}),service.BackupConflict); +}); + +test('operator-bearing incremental account input cannot read or mutate another backup',async()=>{ + const p=await payload('incremental-operator-guard');await service.repairAppBackup(p); + const original=app.findOne;let reads=0;app.findOne=function(...args){reads++;return original.apply(this,args);}; + try { + for(const appId of [{$ne:null},{$in:[p.appId]},[],null,undefined,'']) assert.equal((await legacy.updateAppImage(appId,null,{w:'unauthorized'})).updated,false); + assert.equal(reads,0); + } finally {app.findOne=original;} + assert.equal((await service.getBackupSnapshot(p.appId)).appImage.wallets.w,'encrypted-wallet'); +}); + +test('archiving the final active collaborator removes signer lookups without deleting peer ciphertext',async()=>{ + const p=await collaborativePeers('all-peers-archived'); + for(const [index,peer] of [p.first,p.second].entries()) { + assert.notEqual(await legacy.updateVault(p.vaultId,p.images[index],peer.appId,true,p.signer),false); + assert.equal(await map.countDocuments({vaultId:p.vaultId}),index===0?1:0); + } + assert.equal(await appVault.countDocuments({vaultId:p.vaultId}),2); + for(const peer of [p.first,p.second]) assert.equal((await service.getBackupSnapshot(peer.appId)).allVaultImages[0].isArchived,true); +}); + +test('malformed repair vault entries and empty fingerprints are request errors with no mutation',async()=>{ + const p=await payload('repair-vault-input-guard');await service.repairAppBackup(p); + const before=await service.getBackupSnapshot(p.appId); + for(const value of [null,[],{}, {...vaultRecord('guarded-vault','guarded-key'),signersData:[{signerId:'guarded-key',xfpHash:''}]}]) { + await assert.rejects(service.repairAppBackup({...p,expectedRevision:before.revision,vaultObject:{'guarded-vault':value}}),service.InvalidBackupRequest); + assert.equal((await service.getBackupSnapshot(p.appId)).revision,before.revision); + } +}); + +test('legacy label and signer migrations cannot reuse an earlier content generation',async()=>{ + const p=await payload('generation-legacy-records');p.signersObject={keyA:'encrypted-key'};await service.repairAppBackup(p); + await legacy.modifyLabels(p.appId,[{id:'generation-label',content:'synthetic-label-content'}],[]); + const first=await service.getBackupSnapshot(p.appId); + await legacy.modifyLabels(p.appId,[],['generation-label']); + await legacy.modifyLabels(p.appId,[{id:'generation-label',content:'synthetic-label-content'}],[]); + assert.notEqual((await service.getBackupSnapshot(p.appId)).revision,first.revision); + const before=await service.getBackupSnapshot(p.appId); + assert.equal(await legacy.migrateXfp(p.appId,[{oldSignerId:'keyA',newSignerId:'keyB',newSignerDetails:'encrypted-key'}]),true); + assert.equal(await legacy.migrateXfp(p.appId,[{oldSignerId:'keyB',newSignerId:'keyA',newSignerDetails:'encrypted-key'}]),true); + const repeated=await service.getBackupSnapshot(p.appId); + assert.deepEqual(plain(repeated.appImage.signers),plain(before.appImage.signers));assert.notEqual(repeated.revision,before.revision); +}); + + +test('legacy empty-array wallet and signer maps accept incremental encrypted records without replacing saved data',async()=>{ + const id='legacy-empty-array-maps';await app.create({appId:id,publicId:`public-${id}`,version:'2.5.16',wallets:[],signers:[],nodes:[]}); + assert.equal((await legacy.updateAppImage(id,null,{w:'synthetic-wallet'},null,null,{key:'synthetic-key'})).updated,true); + const snapshot=await service.getBackupSnapshot(id); + assert.deepEqual(plain(snapshot.appImage.wallets),{w:'synthetic-wallet'}); + assert.deepEqual(plain(snapshot.appImage.signers),{key:'synthetic-key'}); +}); diff --git a/tests/bhrIdentifierGuards.test.cjs b/tests/bhrIdentifierGuards.test.cjs new file mode 100644 index 0000000..7adc9c6 --- /dev/null +++ b/tests/bhrIdentifierGuards.test.cjs @@ -0,0 +1,202 @@ +const { test, before, after } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); +const ts = require("typescript"); +const mongoose = require("mongoose"); + +// Never load .env or production config. Seed and drop only this process's +// database on the disposable keeperQA replica set reached through loopback. +const database = `keeper_identifier_guard_test_${process.pid}`; +const uri = require('./disposableMongo.cjs').testMongoUri('keeper_identifier_guard_test'); + +function load(file, mocks) { + const module = { exports: {} }; + const code = ts.transpileModule( + fs.readFileSync(path.join(__dirname, "..", file), "utf8"), + { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }, + ).outputText; + vm.runInNewContext(code, { + module, + exports: module.exports, + global, + console, + require: (name) => (name in mocks ? mocks[name] : require(name)), + }); + return module.exports; +} + +const db = load("src/db.ts", { + "./config": { DATABASE: uri }, + "./interface": { NotificationType: { RELEASE_MESSAGE: "RELEASE_MESSAGE" } }, +}).default; +const serviceMocks = { + "./backupSnapshot": {}, + "../db": db, + "../interface": {}, + "./app": {}, + "../utils/plans": {}, +}; +const bhr = load("src/services/bhr.ts", serviceMocks); +let databaseAccesses = 0; +const rejectDatabaseAccess = () => { + databaseAccesses++; + throw Error("Invalid identifier reached database"); +}; +const guards = load("src/services/bhr.ts", { + ...serviceMocks, + "../db": { + getVaultMapModel: rejectDatabaseAccess, + getVaultImageModel: rejectDatabaseAccess, + }, +}); +const plain = (value) => JSON.parse(JSON.stringify(value)); +let fixtureDatabaseVerified = false; + +before(async () => { + await new Promise((resolve, reject) => { + if (mongoose.connection.readyState === 1) return resolve(); + mongoose.connection.once("open", resolve); + mongoose.connection.once("error", reject); + }); + assert.equal(mongoose.connection.name, database); + assert.equal( + (await mongoose.connection.db.admin().command({ hello: 1 })).setName, + "keeperQA", + ); + fixtureDatabaseVerified = true; + const vault = db.getVaultImageModel(); + const map = db.getVaultMapModel(); + await Promise.all([vault.init(), map.init()]); + await vault.create([ + { + vaultId: "fixture-active-vault", + appId: "fixture-active-account", + vaultShellId: "fixture-active-shell", + vault: "fixture-active-encrypted-image", + isArchived: false, + signerIds: ["fixture-active-signer"], + }, + { + vaultId: "fixture-archived-vault", + appId: "fixture-archived-account", + vaultShellId: "fixture-archived-shell", + vault: "fixture-archived-encrypted-image", + isArchived: true, + signerIds: ["fixture-archived-signer"], + }, + ]); + await map.create([ + { + signerId: "fixture-active-signer", + xfpHash: "fixture-active-hash", + vaultId: "fixture-active-vault", + }, + { + signerId: "fixture-archived-signer", + xfpHash: "fixture-archived-hash", + vaultId: "fixture-archived-vault", + }, + ]); +}); + +after(async () => { + if (fixtureDatabaseVerified && mongoose.connection.name === database) + await mongoose.connection.dropDatabase(); + await mongoose.disconnect(); +}); + +test("operator-object lookups cannot return seeded account metadata or images", async () => { + const operator = { $ne: "" }; + assert.deepEqual(plain(await bhr.getVaultMetaData(operator)), { + error: "No Vault for this signer Id", + }); + assert.equal(await bhr.getSignerIdInfo(operator), false); + assert.equal(await bhr.getVaultImage(operator), undefined); + assert.deepEqual(plain(await bhr.vaultCheck(operator)), { isVault: false }); + assert.deepEqual( + plain(await bhr.getVaultMetaData("fixture-active-hash", operator)), + { error: "No Vault for this signer Id" }, + ); +}); + +test("invalid identifiers are rejected before obtaining a database model", async () => { + const invalid = [ + { $ne: "" }, + { $regex: ".*" }, + { $in: ["fixture-active-hash"] }, + {}, + [], + ["fixture-active-hash"], + true, + false, + 7, + null, + undefined, + "", + " ", + ]; + for (const value of invalid) { + assert.deepEqual(plain(await guards.getVaultMetaData(value)), { + error: "No Vault for this signer Id", + }); + assert.equal(await guards.getSignerIdInfo(value), false); + assert.equal(await guards.getVaultImage(value), undefined); + assert.deepEqual(plain(await guards.vaultCheck(value)), { isVault: false }); + if (value !== undefined) + assert.deepEqual( + plain(await guards.getVaultMetaData("fixture-active-hash", value)), + { error: "No Vault for this signer Id" }, + ); + } + assert.equal(databaseAccesses, 0); +}); + +test("valid string lookups preserve metadata, image and existence contracts", async () => { + const expected = { + appId: "fixture-active-account", + vaultShellId: "fixture-active-shell", + }; + assert.deepEqual( + plain(await bhr.getVaultMetaData("fixture-active-hash")), + expected, + ); + assert.deepEqual( + plain( + await bhr.getVaultMetaData( + "fixture-active-hash", + "fixture-active-signer", + ), + ), + expected, + ); + assert.equal(await bhr.getSignerIdInfo("fixture-active-signer"), true); + assert.equal( + (await bhr.getVaultImage("fixture-active-vault")).vault, + "fixture-active-encrypted-image", + ); + assert.deepEqual(plain(await bhr.vaultCheck("fixture-active-vault")), { + isVault: true, + }); + assert.deepEqual(plain(await bhr.vaultCheck("fixture-archived-vault")), { + isVault: false, + }); +}); + +test("unmatched strings retain existing not-found responses", async () => { + assert.deepEqual(plain(await bhr.getVaultMetaData("fixture-unknown-hash")), { + error: "No Vault for this signer Id", + }); + assert.equal(await bhr.getSignerIdInfo("fixture-unknown-signer"), false); + assert.equal(await bhr.getVaultImage("fixture-unknown-vault"), undefined); + assert.deepEqual(plain(await bhr.vaultCheck("fixture-unknown-vault")), { + isVault: false, + }); +}); diff --git a/tests/disposableMongo.cjs b/tests/disposableMongo.cjs new file mode 100644 index 0000000..003e324 --- /dev/null +++ b/tests/disposableMongo.cjs @@ -0,0 +1,13 @@ +const assert = require('node:assert/strict'); + +// Override only the loopback server address; fixtures always use their own DB. +exports.testMongoUri = prefix => { + assert.match(prefix, /^keeper_[a-z_]+_test$/); + const url = new URL(process.env.BACKUP_TEST_MONGO_URL || 'mongodb://127.0.0.1:27028'); + assert.equal(url.protocol, 'mongodb:', 'Backup tests require disposable MongoDB'); + assert.ok(['127.0.0.1', 'localhost'].includes(url.hostname), 'Backup tests require loopback MongoDB'); + assert.ok(!url.username && !url.password, 'Backup tests must not use hosted credentials'); + url.pathname = `/${prefix}_${process.pid}`; + url.search = '?directConnection=true&replicaSet=keeperQA'; + return url.toString(); +}; diff --git a/tests/nodeReplacement.test.cjs b/tests/nodeReplacement.test.cjs new file mode 100644 index 0000000..025e2de --- /dev/null +++ b/tests/nodeReplacement.test.cjs @@ -0,0 +1,165 @@ +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const ts = require('typescript'); +const mongoose = require('mongoose'); +// No .env or integration configuration: this suite can touch only its own +// uniquely named disposable loopback database on the Keeper QA replica set. +const database = `keeper_node_replacement_test_${process.pid}`; +const uri = require('./disposableMongo.cjs').testMongoUri('keeper_node_replacement_test'); +function load(file, mocks = {}) { + const module = { exports: {} }; + const code = ts.transpileModule(fs.readFileSync(path.join(__dirname, '..', file), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true }, + }).outputText; + vm.runInNewContext(code, { module, exports: module.exports, global, console, + require: name => name in mocks ? mocks[name] : require(name) }); + return module.exports; +} +const db = load('src/db.ts', { './config': { DATABASE: uri }, './interface': { NotificationType: { RELEASE_MESSAGE: 'RELEASE_MESSAGE' } } }).default; +const service = load('src/services/bhr.ts', { './backupSnapshot': load('src/services/backupSnapshot.ts', { '../db': db }), '../db': db, '../interface': {}, './app': { getAppSubscriptionDetails: async () => null }, '../utils/plans': { getPlans: () => [] } }); +let app; +before(async () => { + await new Promise((resolve, reject) => { + if (mongoose.connection.readyState === 1) return resolve(); + mongoose.connection.once('open', resolve); mongoose.connection.once('error', reject); + }); + app = db.getAppImageModel(); await app.init(); +}); +after(async () => { await mongoose.connection.dropDatabase(); await mongoose.disconnect(); }); +async function seed(id) { + await app.create({ appId: id, publicId: `public-${id}`, version: '2.5.16', + wallets: { existing: 'encrypted-wallet' }, signers: { existing: 'encrypted-signer' }, + nodes: ['encrypted-node'], vaults: [], labels: [] }); +} +const stored = id => app.findOne({ appId: id }).lean(); + +test('explicit empty node replacement removes the final backed-up node', async () => { + await seed('clear'); + assert.equal((await service.updateAppImage('clear', null, null, null, null, null, [], true)).updated, true); + const record = await stored('clear'); + assert.deepEqual(record.nodes, []); + assert.equal(record.wallets.existing, 'encrypted-wallet'); + assert.equal(record.signers.existing, 'encrypted-signer'); +}); + +for (const flag of [undefined, false]) test(`unrelated wallet/key updates with empty nodes preserve saved nodes: ${flag}`, async () => { + const id = `unrelated-${flag}`; await seed(id); + assert.equal((await service.updateAppImage(id, null, { added: 'new-wallet' }, null, '2.5.17', { added: 'new-signer' }, [], flag)).updated, true); + const record = await stored(id); + assert.deepEqual(record.nodes, ['encrypted-node']); + assert.equal(record.wallets.added, 'new-wallet'); assert.equal(record.signers.added, 'new-signer'); +}); + +test('legacy nonempty node updates retain their previous behavior', async () => { + await seed('legacy'); + assert.equal((await service.updateAppImage('legacy', null, null, null, null, null, ['legacy-new-node'])).updated, true); + assert.deepEqual((await stored('legacy')).nodes, ['legacy-new-node']); +}); + +test('explicit nonempty replacement stores the new list', async () => { + await seed('replace'); + assert.equal((await service.updateAppImage('replace', null, null, null, null, null, ['one', 'two'], true)).updated, true); + assert.deepEqual((await stored('replace')).nodes, ['one', 'two']); +}); + +for (const nodes of [undefined, {}, [null], ['']]) test(`malformed explicit replacement cannot clear nodes: ${JSON.stringify(nodes)}`, async () => { + const id = `invalid-${JSON.stringify(nodes)}`; await seed(id); + assert.equal((await service.updateAppImage(id, null, null, null, null, null, nodes, true)).updated, false); + assert.deepEqual((await stored(id)).nodes, ['encrypted-node']); +}); + +test('concurrent unrelated wallet update and explicit node clearing both survive', async () => { + await seed('concurrent'); + const responses = await Promise.all([ + service.updateAppImage('concurrent', null, { added: 'new-wallet' }, null, null, null, []), + service.updateAppImage('concurrent', null, null, null, null, null, [], true), + ]); + assert.ok(responses.every(result => result.updated)); + const record = await stored('concurrent'); + assert.deepEqual(record.nodes, []); assert.equal(record.wallets.added, 'new-wallet'); +}); + +// Exercise the actual registered route callback against the real service/DB, +// without loading unrelated production services or opening a network listener. +function routeHandler() { + const file = 'src/routes/routes.ts'; + const source = ts.createSourceFile(file, fs.readFileSync(path.join(__dirname, '..', file), 'utf8'), ts.ScriptTarget.Latest, true); + let registration; + function visit(node) { + if (ts.isCallExpression(node) && node.expression.getText(source) === 'router.post' && + node.arguments[0]?.text === '/updateAppImage') registration = node; + ts.forEachChild(node, visit); + } + visit(source); assert.ok(registration); + let callback; + vm.runInNewContext(ts.transpileModule(registration.getText(source), { + compilerOptions: { target: ts.ScriptTarget.ES2020 }, + }).outputText, { bhr: service, router: { post: (_path, handler) => { callback = handler; } } }); + return callback; +} +for (const flag of [true, 'true']) test(`route requires a boolean replacement request: ${typeof flag}`, async () => { + const id = `route-${typeof flag}`; await seed(id); + let status, body; + const response = { status(code) { status = code; return this; }, json(value) { body = value; return this; } }; + await routeHandler()({ body: { appId: id, nodes: [], replaceNodes: flag } }, response); + assert.equal(status, 200); assert.equal(body.updated, true); + assert.deepEqual((await stored(id)).nodes, flag === true ? [] : ['encrypted-node']); +}); + +async function postUpdate(body) { + let status, result; + const response = { status(code) { status = code; return this; }, json(value) { result = value; return this; } }; + await routeHandler()({ body }, response); + return { status, result }; +} + +test('v1.1.9 walletObject route payload persists an incremental encrypted wallet', async () => { + const id = 'legacy-wallet-field'; await seed(id); + const response = await postUpdate({ appId: id, walletObject: { added: 'legacy-wallet-ciphertext' }, nodes: [] }); + assert.equal(response.status, 200); assert.equal(response.result.updated, true); + const record = await stored(id); + assert.equal(record.wallets.added, 'legacy-wallet-ciphertext'); + assert.equal(record.wallets.existing, 'encrypted-wallet'); + assert.deepEqual(record.nodes, ['encrypted-node']); +}); + +test('v1.1.11–v2.1.0 walletObject and signersObjects payload persists both incremental maps', async () => { + const id = 'legacy-wallet-signer-fields'; await seed(id); + const response = await postUpdate({ appId: id, + walletObject: { added: 'legacy-wallet-ciphertext' }, + signersObjects: { added: 'legacy-signer-ciphertext' } }); + assert.equal(response.status, 200); assert.equal(response.result.updated, true); + const record = await stored(id); + assert.equal(record.wallets.added, 'legacy-wallet-ciphertext'); + assert.equal(record.signers.added, 'legacy-signer-ciphertext'); + assert.equal(record.wallets.existing, 'encrypted-wallet'); + assert.equal(record.signers.existing, 'encrypted-signer'); +}); + +test('current route field names continue to persist encrypted wallet and signer records', async () => { + const id = 'current-wallet-signer-fields'; await seed(id); + const response = await postUpdate({ appId: id, + walletsObject: { added: 'current-wallet-ciphertext' }, + signersObject: { added: 'current-signer-ciphertext' } }); + assert.equal(response.status, 200); assert.equal(response.result.updated, true); + const record = await stored(id); + assert.equal(record.wallets.added, 'current-wallet-ciphertext'); + assert.equal(record.signers.added, 'current-signer-ciphertext'); +}); + +for (const [canonical, legacy] of [['walletsObject', 'walletObject'], ['signersObject', 'signersObjects']]) { + test(`ambiguous ${canonical}/${legacy} route fields reject before mutating the backup`, async () => { + const id = `conflicting-${canonical}`; await seed(id); + const before = await stored(id); + const response = await postUpdate({ appId: id, + [canonical]: { added: 'canonical-ciphertext' }, [legacy]: { added: 'legacy-ciphertext' } }); + assert.equal(response.status, 400); assert.equal(response.result.updated, false); + const after = await stored(id); + assert.deepEqual(after.wallets, before.wallets); + assert.deepEqual(after.signers, before.signers); + assert.equal(after.backupGeneration, before.backupGeneration); + }); +} diff --git a/tests/recoveryRoute.test.cjs b/tests/recoveryRoute.test.cjs new file mode 100644 index 0000000..0c5b0bd --- /dev/null +++ b/tests/recoveryRoute.test.cjs @@ -0,0 +1,43 @@ +const {test}=require('node:test');const assert=require('node:assert/strict'); +const fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'),ts=require('typescript'); +function route(service, name = '/getAppImage', dependencies = {}){ + const source=ts.createSourceFile('routes.ts',fs.readFileSync(path.join(__dirname,'../src/routes/routes.ts'),'utf8'),ts.ScriptTarget.Latest,true);let registration,handler; + function visit(n){if(ts.isCallExpression(n)&&n.expression.getText(source)==='router.post'&&n.arguments[0]?.text===name)registration=n;ts.forEachChild(n,visit);}visit(source);assert.ok(registration); + vm.runInNewContext(ts.transpileModule(registration.getText(source),{compilerOptions:{target:ts.ScriptTarget.ES2020}}).outputText,{...dependencies,bhr:service,router:{post:(_p,h)=>handler=h}});return handler; +} +test('recovery backend failure completes with a retryable response and no sensitive exception',async()=>{ + let status,body;const res={status(c){status=c;return this},json(b){body=b;return this}}; + await route({getAppImage:async()=>{throw Error('sensitive database details')}})({body:{appId:'disposable'},headers:{appversion:'2.5.17'}},res); + assert.equal(status,503);assert.equal(body.err,'Backup temporarily unavailable'); +}); +test('successful encrypted recovery response remains unchanged',async()=>{ + let status,body;const image={appImage:{wallets:{w:'ciphertext'}},labels:[],allVaultImages:[]};const res={status(c){status=c;return this},json(b){body=b;return this}}; + await route({getAppImage:async()=>image})({body:{appId:'disposable'},headers:{appversion:'2.5.17'}},res);assert.equal(status,200);assert.equal(body,image); +}); + +const mockResponse = () => { + let status, body; + return {status(c){status=c;return this;},json(b){body=b;return this;},get result(){return {status,body};}}; +}; +for (const appId of [undefined, '', {}, {$ne:null}]) test(`snapshot rejects invalid account before database access: ${JSON.stringify(appId)}`, async () => { + let calls=0; const res=mockResponse(); + await route({}, '/getBackupSnapshot', {getBackupSnapshot:async()=>{calls++;}})({body:{appId}},res); + assert.equal(res.result.status,400); assert.equal(calls,0); +}); +test('snapshot database failure terminates with a fixed retryable error',async()=>{ + const res=mockResponse(); + await route({}, '/getBackupSnapshot', {getBackupSnapshot:async()=>{throw Error('synthetic-private-canary');}})({body:{appId:'disposable'}},res); + assert.equal(res.result.status,503); assert.equal(res.result.body.error,'Backup snapshot unavailable'); +}); +test('snapshot success preserves revision and encrypted records',async()=>{ + const res=mockResponse(), snapshot={revision:'a'.repeat(64),appImage:{wallets:{w:'synthetic-ciphertext'}},labels:[],allVaultImages:[]}; + await route({}, '/getBackupSnapshot', {getBackupSnapshot:async()=>snapshot})({body:{appId:'disposable'}},res); + assert.equal(res.result.status,200);assert.equal(res.result.body,snapshot); +}); +class BackupConflict extends Error {} +class InvalidBackupRequest extends Error {} +for (const [error,status,message] of [[new BackupConflict(),409,'BACKUP_CHANGED'],[new InvalidBackupRequest(),400,'BACKUP_NOT_UPDATED'],[new Error('synthetic-private-canary'),503,'BACKUP_NOT_UPDATED']]) test(`repair failure returns stable code ${status}`,async()=>{ + const res=mockResponse(); + await route({}, '/repairAppBackup', {BackupConflict,InvalidBackupRequest,repairAppBackup:async()=>{throw error;}})({body:{appId:'disposable'}},res); + assert.equal(res.result.status,status); assert.equal(res.result.body.updated,false); assert.equal(res.result.body.error,message); +});