Repository: buttercup/buttercup-core
Pull request #324 (2023-02-18 20:51:45)

Live Snapshots
Live snapshot functionality for storing the unlocked state in system storage (primarily for browser extensions).


Patch:
diff --git a/source/core/Vault.ts b/source/core/Vault.ts
index 0f8d0b3..c6b7802 100644
--- a/source/core/Vault.ts
+++ b/source/core/Vault.ts
@@ -52,7 +52,7 @@ export class Vault extends EventEmitter {
 
     _entries: Array<Entry> = [];
 
-    _format: any;
+    _format: VaultFormat;
 
     _groups: Array<Group> = [];
 
diff --git a/source/core/VaultManager.ts b/source/core/VaultManager.ts
index 22ee73e..8276abc 100644
--- a/source/core/VaultManager.ts
+++ b/source/core/VaultManager.ts
@@ -135,7 +135,7 @@ export class VaultManager extends EventEmitter {
     }
 
     /**
-     * Dehydrate a single archive source
+     * Dehydrate a single vault source
      * @param sourceID The ID of the source
      * @returns A promise that resolves once the source has been dehydrated
      * @memberof VaultManager
@@ -155,6 +155,8 @@ export class VaultManager extends EventEmitter {
         return this._queue.channel("state").enqueue(cb);
     }
 
+    getLiveSnapshot() {}
+
     /**
      * Get the next viable order number for a new source
      * @returns The new order
diff --git a/source/core/VaultSource.ts b/source/core/VaultSource.ts
index fc2d680..d82f814 100644
--- a/source/core/VaultSource.ts
+++ b/source/core/VaultSource.ts
@@ -3,7 +3,7 @@ import { ChannelQueue } from "@buttercup/channel-queue";
 import { Layerr } from "layerr";
 import { Vault } from "./Vault.js";
 import { Credentials } from "../credentials/Credentials.js";
-import { getCredentials } from "../credentials/channel.js";
+import { getCredentials, setCredentials } from "../credentials/channel.js";
 import { getUniqueID } from "../tools/encoding.js";
 import { getSourceOfflineArchive, sourceHasOfflineCopy, storeSourceOfflineCopy } from "../tools/vaultManagement.js";
 import { credentialsToDatasource, prepareDatasourceCredentials } from "../datasources/register.js";
@@ -13,7 +13,8 @@ import { TextDatasource } from "../datasources/TextDatasource.js";
 import { VaultManager } from "./VaultManager.js";
 import { convertFormatAVault } from "../io/formatB/conversion.js";
 import { VaultFormatB } from "../index.common.js";
-import { VaultFormatID, VaultSourceID, VaultSourceStatus } from "../types.js";
+import { VaultFormatID, VaultLiveSnapshot, VaultSourceID, VaultSourceStatus } from "../types.js";
+import { getFormatForID } from "../io/formatRouter.js";
 
 interface StateChangeEnqueuedFunction {
     (): void | Promise<any>;
@@ -346,6 +347,26 @@ export class VaultSource extends EventEmitter {
         });
     }
 
+    /**
+     * Get a live snapshot of the current unlocked state
+     * @returns A snapshot object
+     */
+    getLiveSnapshot(): VaultLiveSnapshot {
+        if (this.status !== VaultSourceStatus.Unlocked) {
+            throw new Layerr("Not possible to fetch live snapshot: Vault is not unlocked");
+        }
+        const credentials = getCredentials((this._credentials as Credentials).id);
+        if (!credentials) {
+            throw new Layerr("Failed fetching live snapshot: Invalid credentials data");
+        }
+        return {
+            credentials,
+            formatID: this.vault._format.getFormat().getFormatID(),
+            formatSource: this.vault._format.source,
+            version: "1a"
+        };
+    }
+
     /**
      * Get offline content, if it exists
      * @returns A promise a resolves with the content, or null
@@ -456,11 +477,44 @@ export class VaultSource extends EventEmitter {
      * @param name The new name
      * @memberof VaultSource
      */
-    rename(name: string) {
+    rename(name: string): void {
         this._name = name;
         this.emit("updated");
     }
 
+    /**
+     * Restore unlocked state from a live snapshot
+     * @param snapshot The snapshot taken previously
+     */
+    async restoreFromLiveSnapshot(snapshot: VaultLiveSnapshot): Promise<void> {
+        if (this.status !== VaultSourceStatus.Locked) {
+            throw new Layerr("Cannot restore live snapshot: Vault is not locked");
+        }
+        if (snapshot.version !== "1a") {
+            throw new Layerr(
+                `Failed restoring live snapshot: Snapshot version unsupported or unrecognised: ${snapshot.version}`
+            );
+        }
+        // Setup credentials and datasource
+        const credentials = (this._credentials = new Credentials(
+            snapshot.credentials.data,
+            snapshot.credentials.masterPassword
+        ));
+        setCredentials(credentials.id, snapshot.credentials);
+        // Initialise datasource
+        const datasource = (this._datasource = credentialsToDatasource(
+            Credentials.fromCredentials(credentials, snapshot.credentials.masterPassword)
+        ));
+        datasource.sourceID = this.id;
+        // Setup vault
+        const Format = getFormatForID(snapshot.formatID);
+        this._vault = new Vault(new Format(snapshot.formatSource));
+        // Set statuses
+        this._status = VaultSource.STATUS_UNLOCKED;
+        this._attachmentManager = new AttachmentManager(this);
+        this.emit("unlocked");
+    }
+
     /**
      * Save the vault to the remote, ensuring that it's first merged and
      * updated to prevent conflicts or overwrites.
diff --git a/source/credentials/Credentials.ts b/source/credentials/Credentials.ts
index ee292e7..5fadbeb 100644
--- a/source/credentials/Credentials.ts
+++ b/source/credentials/Credentials.ts
@@ -190,7 +190,7 @@ export class Credentials {
      * Get raw credentials data (only available in specialised environments)
      * @returns Credentials data object, or null if not available
      */
-    getCredentialsData(): CredentialsData {
+    getCredentialsData(): CredentialsData | null {
         const isClosedEnv = getSharedAppEnv().getProperty("env/v1/isClosedEnv")();
         const payload = getCredentials(this.id);
         if (isClosedEnv || payload.open === true) {
@@ -201,7 +201,6 @@ export class Credentials {
 
     /**
      * Get raw credentials data (only available in specialised environments)
-     * @deprecated Use getCredentialsData instead
      */
     getData(): CredentialsPayload | null {
         const isClosedEnv = getSharedAppEnv().getProperty("env/v1/isClosedEnv")();
diff --git a/source/io/VaultFormat.ts b/source/io/VaultFormat.ts
index c4f777d..8c7d0ca 100644
--- a/source/io/VaultFormat.ts
+++ b/source/io/VaultFormat.ts
@@ -56,9 +56,9 @@ export class VaultFormat extends EventEmitter {
         return null;
     }
 
-    _readOnly = false;
-    dirty = false;
-    source: FormatAVault | FormatBVault = null;
+    protected _readOnly = false;
+    public dirty = false;
+    public source: FormatAVault | FormatBVault = null;
 
     get history() {
         return this.getHistory();
diff --git a/source/io/VaultFormatA.ts b/source/io/VaultFormatA.ts
index fc73e48..6391d0d 100644
--- a/source/io/VaultFormatA.ts
+++ b/source/io/VaultFormatA.ts
@@ -189,8 +189,8 @@ export class VaultFormatA extends VaultFormat {
         return newVault;
     }
 
-    _history: History;
-    source: FormatAVault;
+    protected _history: History;
+    public source: FormatAVault;
 
     constructor(source: FormatAVault = emptyVault()) {
         super(source);
diff --git a/source/io/VaultFormatB.ts b/source/io/VaultFormatB.ts
index bc17f0d..5a5ba41 100644
--- a/source/io/VaultFormatB.ts
+++ b/source/io/VaultFormatB.ts
@@ -95,7 +95,7 @@ export class VaultFormatB extends VaultFormat {
         return vault;
     }
 
-    source: FormatBVault;
+    public source: FormatBVault;
 
     constructor(source: FormatBVault = emptyVault()) {
         super(source);
diff --git a/source/types.ts b/source/types.ts
index 07bde51..cbe9b53 100644
--- a/source/types.ts
+++ b/source/types.ts
@@ -254,6 +254,13 @@ export enum VaultFormatID {
 
 export type VaultID = string;
 
+export interface VaultLiveSnapshot {
+    credentials: CredentialsPayload;
+    formatID: VaultFormatID;
+    formatSource: FormatAVault | FormatBVault;
+    version: "1a";
+}
+
 export enum VaultPermission {
     Manage = "archive/member/manage",
     Read = "archive/member/read",


Test patch:
diff --git a/test/integration/core/VaultSource.spec.js b/test/integration/core/VaultSource.spec.js
index bba4b58..a2a852a 100644
--- a/test/integration/core/VaultSource.spec.js
+++ b/test/integration/core/VaultSource.spec.js
@@ -8,7 +8,8 @@ import {
     VaultFormatID,
     VaultManager,
     VaultSource,
-    setDefaultFormat
+    setDefaultFormat,
+    VaultSourceStatus
 } from "../../../dist/node/index.js";
 
 async function createTextSourceCredentials() {
@@ -82,6 +83,29 @@ describe("VaultSource", function() {
                     });
                 }
 
+                describe("using live snapshots", function() {
+                    it("can collect relevant data", function() {
+                        const snapshot = this.vaultSource.getLiveSnapshot();
+                        expect(snapshot)
+                            .to.have.nested.property("credentials.masterPassword")
+                            .that.is.a("string");
+                        expect(snapshot).to.have.property("formatID", Format.getFormatID());
+                        expect(snapshot).to.have.property("version", "1a");
+                    });
+
+                    it("can restore vault source unlocked state", async function() {
+                        const snapshot = this.vaultSource.getLiveSnapshot();
+                        await this.vaultSource.lock();
+                        expect(this.vaultSource.status).to.equal(VaultSourceStatus.Locked);
+                        expect(this.vaultSource.attachmentManager).to.be.null;
+                        await this.vaultSource.restoreFromLiveSnapshot(snapshot);
+                        expect(this.vaultSource.status).to.equal(VaultSourceStatus.Unlocked);
+                        expect(this.vaultSource.attachmentManager).to.not.be.null;
+                        const passwordWorks = await this.vaultSource.testMasterPassword("test");
+                        expect(passwordWorks).to.equal(true, "Password should match");
+                    });
+                });
+
                 describe("save", function() {
                     it("saves changes", async function() {
                         let [loginEntry] = this.vaultSource.vault.findEntriesByProperty("title", "Login");