[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
573
electron/plugins/secretStore.test.cjs
Normal file
573
electron/plugins/secretStore.test.cjs
Normal file
@@ -0,0 +1,573 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const { PluginCredentialBroker } = require("./credentialBroker.cjs");
|
||||
const { PluginDatabase } = require("./database.cjs");
|
||||
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const { SecretLeaseStore } = require("./secretLease.cjs");
|
||||
const { PluginSecretStore } = require("./secretStore.cjs");
|
||||
|
||||
function createDatabase(context) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-secrets-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return new PluginDatabase(path.join(root, "plugins.sqlite"));
|
||||
}
|
||||
|
||||
function fakeSafeStorage() {
|
||||
return {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value) => Buffer.from(`sealed:${Buffer.from(value).toString("base64")}`),
|
||||
decryptString: (value) => {
|
||||
const encoded = value.toString().slice("sealed:".length);
|
||||
return Buffer.from(encoded, "base64").toString();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("plugin secrets are OS-encrypted, opaque, ownership-bound, and uninstall-independent", (context) => {
|
||||
const database = createDatabase(context);
|
||||
let random = 0;
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
randomBytes: () => Buffer.alloc(24, ++random),
|
||||
});
|
||||
const secret = store.set("com.example.one", "api-key", "plaintext-token");
|
||||
const record = database.getSecretByKey("com.example.one", "api-key");
|
||||
assert.equal(record.ciphertext.includes(Buffer.from("plaintext-token")), false);
|
||||
assert.deepEqual(store.getReference("com.example.one", "api-key"), secret);
|
||||
assert.equal(store.resolve("com.example.one", secret), "plaintext-token");
|
||||
assert.throws(
|
||||
() => store.resolve("com.example.two", secret),
|
||||
(error) => error.code === RPC_ERRORS.notFound,
|
||||
);
|
||||
assert.throws(
|
||||
() => store.resolve("com.example.one", { ...secret, key: "different-key" }),
|
||||
(error) => error.code === RPC_ERRORS.notFound,
|
||||
);
|
||||
store.delete("com.example.one", "api-key");
|
||||
assert.equal(store.getReference("com.example.one", "api-key"), undefined);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("plugin secrets fail closed when OS encryption is unavailable or ciphertext is corrupt", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const unavailable = new PluginSecretStore({ database, safeStorage: null });
|
||||
assert.throws(
|
||||
() => unavailable.set("com.example.one", "api-key", "value"),
|
||||
(error) => error.code === RPC_ERRORS.unavailable,
|
||||
);
|
||||
const insecureBackend = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: {
|
||||
...fakeSafeStorage(),
|
||||
getSelectedStorageBackend: () => "basic_text",
|
||||
},
|
||||
});
|
||||
assert.throws(
|
||||
() => insecureBackend.set("com.example.one", "api-key", "value"),
|
||||
(error) => error.code === RPC_ERRORS.unavailable,
|
||||
);
|
||||
|
||||
const safeStorage = fakeSafeStorage();
|
||||
const store = new PluginSecretStore({ database, safeStorage });
|
||||
const secret = store.set("com.example.one", "api-key", "value");
|
||||
safeStorage.decryptString = () => { throw new Error("corrupt"); };
|
||||
assert.throws(
|
||||
() => store.resolve("com.example.one", secret),
|
||||
(error) => error.code === RPC_ERRORS.dataLoss,
|
||||
);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("credential leases are one-use and bound to plugin, runtime, operation, abort, and expiry", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
let now = 1_000;
|
||||
let random = 0;
|
||||
const secretStore = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
randomBytes: () => Buffer.alloc(24, ++random),
|
||||
});
|
||||
const secret = secretStore.set("com.example.one", "api-key", "credential-value");
|
||||
const leaseStore = new SecretLeaseStore({
|
||||
secretStore,
|
||||
clock: () => now,
|
||||
randomBytes: () => Buffer.alloc(24, ++random),
|
||||
setTimeout: () => ({ timer: true }),
|
||||
clearTimeout: () => {},
|
||||
});
|
||||
const broker = new PluginCredentialBroker({ secretStore, leaseStore });
|
||||
const runtime = {
|
||||
pluginId: "com.example.one",
|
||||
runtimeId: "runtime-1",
|
||||
signal: new AbortController().signal,
|
||||
assertActive: async () => {},
|
||||
};
|
||||
const lease = leaseStore.issue({
|
||||
...runtime,
|
||||
secret,
|
||||
operationId: "network:login",
|
||||
purpose: "Authenticate",
|
||||
ttlMs: 500,
|
||||
});
|
||||
await assert.rejects(
|
||||
broker.consumeLease({ ...runtime, runtimeId: "runtime-2" }, lease, "network:login"),
|
||||
(error) => error.code === RPC_ERRORS.permissionDenied,
|
||||
);
|
||||
assert.equal(await broker.consumeLease(runtime, lease, "network:login"), "credential-value");
|
||||
await assert.rejects(
|
||||
broker.consumeLease(runtime, lease, "network:login"),
|
||||
(error) => error.code === RPC_ERRORS.notFound,
|
||||
);
|
||||
|
||||
const expired = leaseStore.issue({
|
||||
...runtime,
|
||||
secret,
|
||||
operationId: "network:expired",
|
||||
purpose: "Expire",
|
||||
ttlMs: 1,
|
||||
});
|
||||
now += 2;
|
||||
await assert.rejects(
|
||||
broker.consumeLease(runtime, expired, "network:expired"),
|
||||
(error) => error.code === RPC_ERRORS.notFound,
|
||||
);
|
||||
|
||||
const controller = new AbortController();
|
||||
const aborted = leaseStore.issue({
|
||||
...runtime,
|
||||
signal: controller.signal,
|
||||
secret,
|
||||
operationId: "network:aborted",
|
||||
purpose: "Abort",
|
||||
});
|
||||
controller.abort();
|
||||
await assert.rejects(
|
||||
broker.consumeLease(runtime, aborted, "network:aborted"),
|
||||
(error) => error.code === RPC_ERRORS.notFound,
|
||||
);
|
||||
leaseStore.shutdown();
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("Netcatty credentials resolve only when a one-use provider lease is consumed", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
const secretStore = new PluginSecretStore({ database, safeStorage: fakeSafeStorage() });
|
||||
const leaseStore = new SecretLeaseStore({ secretStore });
|
||||
let resolved = 0;
|
||||
let referenceChecks = 0;
|
||||
const broker = new PluginCredentialBroker({
|
||||
secretStore,
|
||||
leaseStore,
|
||||
credentialResolver: {
|
||||
assertReference: async (reference, leaseContext) => {
|
||||
referenceChecks += 1;
|
||||
assert.equal(reference.id, "vault-credential-1");
|
||||
assert.equal(leaseContext.operationId, "connection:open-1");
|
||||
},
|
||||
resolve: async (reference, consumeContext) => {
|
||||
resolved += 1;
|
||||
assert.equal(reference.id, "vault-credential-1");
|
||||
assert.equal(consumeContext.pluginId, "com.example.provider");
|
||||
assert.equal(consumeContext.purpose, "Authenticate connection");
|
||||
return "host-owned-plaintext";
|
||||
},
|
||||
},
|
||||
});
|
||||
const runtime = {
|
||||
pluginId: "com.example.provider",
|
||||
runtimeId: "runtime-provider",
|
||||
signal: new AbortController().signal,
|
||||
assertActive: async () => {},
|
||||
};
|
||||
const params = {
|
||||
secret: { kind: "credential", id: "vault-credential-1" },
|
||||
operationId: "connection:open-1",
|
||||
purpose: "Authenticate connection",
|
||||
};
|
||||
assert.deepEqual(broker.describeAuthorization(params).resources, ["credential:vault-credential-1"]);
|
||||
assert.equal(referenceChecks, 0);
|
||||
const lease = await broker.createLease(params, runtime);
|
||||
assert.equal(referenceChecks, 1);
|
||||
assert.equal(resolved, 0);
|
||||
assert.equal(await broker.consumeLease(runtime, lease, "connection:open-1"), "host-owned-plaintext");
|
||||
assert.equal(resolved, 1);
|
||||
await assert.rejects(
|
||||
broker.consumeLease(runtime, lease, "connection:open-1"),
|
||||
(error) => error.code === RPC_ERRORS.notFound,
|
||||
);
|
||||
leaseStore.shutdown();
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("credential authorization descriptors do not probe opaque references before permission", async () => {
|
||||
let credentialChecks = 0;
|
||||
let secretChecks = 0;
|
||||
const broker = new PluginCredentialBroker({
|
||||
secretStore: {
|
||||
getRecordByReference() {
|
||||
secretChecks += 1;
|
||||
throw new Error("unknown plugin secret");
|
||||
},
|
||||
},
|
||||
leaseStore: { issue: () => { throw new Error("lease must not be issued"); } },
|
||||
credentialResolver: {
|
||||
async assertReference() {
|
||||
credentialChecks += 1;
|
||||
throw new Error("unknown Netcatty credential");
|
||||
},
|
||||
async resolve() { throw new Error("credential must not resolve"); },
|
||||
},
|
||||
});
|
||||
const base = {
|
||||
operationId: "connection:open-1",
|
||||
purpose: "Authenticate connection",
|
||||
};
|
||||
assert.deepEqual(broker.describeAuthorization({
|
||||
...base,
|
||||
secret: { kind: "credential", id: "unknown-credential" },
|
||||
}).resources, ["credential:unknown-credential"]);
|
||||
assert.deepEqual(broker.describeAuthorization({
|
||||
...base,
|
||||
secret: { kind: "secret", id: "unknown-secret-reference", key: "api-key" },
|
||||
}).resources, ["secret:api-key"]);
|
||||
assert.equal(credentialChecks, 0);
|
||||
assert.equal(secretChecks, 0);
|
||||
|
||||
const runtime = {
|
||||
pluginId: "com.example.provider",
|
||||
runtimeId: "runtime-provider",
|
||||
signal: new AbortController().signal,
|
||||
assertActive: async () => {},
|
||||
};
|
||||
await assert.rejects(broker.createLease({
|
||||
...base,
|
||||
secret: { kind: "credential", id: "unknown-credential" },
|
||||
}, runtime), /unknown Netcatty credential/);
|
||||
assert.equal(credentialChecks, 1);
|
||||
await assert.rejects(broker.createLease({
|
||||
...base,
|
||||
secret: { kind: "secret", id: "unknown-secret-reference", key: "api-key" },
|
||||
}, runtime), /unknown plugin secret/);
|
||||
assert.equal(secretChecks, 1);
|
||||
});
|
||||
|
||||
test("credential lease resolution cannot return plaintext after operation cancellation", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
const secretStore = new PluginSecretStore({ database, safeStorage: fakeSafeStorage() });
|
||||
const leaseStore = new SecretLeaseStore({ secretStore });
|
||||
const controller = new AbortController();
|
||||
let releaseResolution;
|
||||
let resolutionSignal;
|
||||
const broker = new PluginCredentialBroker({
|
||||
secretStore,
|
||||
leaseStore,
|
||||
credentialResolver: {
|
||||
assertReference: async () => {},
|
||||
resolve: async (_reference, consumeContext) => {
|
||||
resolutionSignal = consumeContext.signal;
|
||||
await new Promise((resolve) => { releaseResolution = resolve; });
|
||||
return "must-not-escape-after-cancel";
|
||||
},
|
||||
},
|
||||
});
|
||||
const runtime = {
|
||||
pluginId: "com.example.provider",
|
||||
runtimeId: "runtime-provider",
|
||||
signal: controller.signal,
|
||||
assertActive: async () => controller.signal.throwIfAborted(),
|
||||
};
|
||||
const params = {
|
||||
secret: { kind: "credential", id: "vault-credential-1" },
|
||||
operationId: "connection:open-1",
|
||||
purpose: "Authenticate connection",
|
||||
};
|
||||
const lease = await broker.createLease(params, runtime);
|
||||
const consuming = broker.consumeLease(runtime, lease, "connection:open-1");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(resolutionSignal, controller.signal);
|
||||
controller.abort();
|
||||
releaseResolution();
|
||||
await assert.rejects(
|
||||
consuming,
|
||||
(error) => error.code === RPC_ERRORS.cancelled,
|
||||
);
|
||||
leaseStore.shutdown();
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("overwrite stash restores previous plaintext and discard clears it", (context) => {
|
||||
const database = createDatabase(context);
|
||||
let random = 0;
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
randomBytes: () => Buffer.alloc(24, ++random),
|
||||
});
|
||||
const first = store.set("com.example.one", "sync-credential", "good-password");
|
||||
assert.equal(store.resolve("com.example.one", first), "good-password");
|
||||
const second = store.set("com.example.one", "sync-credential", "bad-password", { stashPrevious: true });
|
||||
assert.equal(store.resolve("com.example.one", second), "bad-password");
|
||||
assert.equal(store.restoreOverwrite("com.example.one", "sync-credential"), true);
|
||||
const restored = store.getReference("com.example.one", "sync-credential");
|
||||
assert.equal(restored.id, first.id, "restore must keep the prior SecretRef id");
|
||||
assert.equal(store.resolve("com.example.one", first), "good-password");
|
||||
assert.equal(store.restoreOverwrite("com.example.one", "sync-credential"), false);
|
||||
|
||||
store.set("com.example.one", "sync-credential", "another-bad", { stashPrevious: true });
|
||||
store.clearOverwriteStash("com.example.one", "sync-credential");
|
||||
assert.equal(store.restoreOverwrite("com.example.one", "sync-credential"), false);
|
||||
assert.equal(
|
||||
store.resolve("com.example.one", store.getReference("com.example.one", "sync-credential")),
|
||||
"another-bad",
|
||||
);
|
||||
// Ordinary secrets.set-style overwrites must not stash plaintext by default.
|
||||
store.set("com.example.one", "api-key", "one");
|
||||
store.set("com.example.one", "api-key", "two");
|
||||
assert.equal(store.restoreOverwrite("com.example.one", "api-key"), false);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("failed overwrite clears stashed plaintext", (context) => {
|
||||
const database = createDatabase(context);
|
||||
let failEncrypt = false;
|
||||
const safeStorage = {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value) => {
|
||||
if (failEncrypt) return Buffer.alloc(0);
|
||||
return Buffer.from(`sealed:${Buffer.from(value).toString("base64")}`);
|
||||
},
|
||||
decryptString: (value) => {
|
||||
const encoded = value.toString().slice("sealed:".length);
|
||||
return Buffer.from(encoded, "base64").toString();
|
||||
},
|
||||
};
|
||||
const store = new PluginSecretStore({ database, safeStorage });
|
||||
store.set("com.example", "sync-credential", "good-password");
|
||||
failEncrypt = true;
|
||||
assert.throws(
|
||||
() => store.set("com.example", "sync-credential", "bad-password", { stashPrevious: true }),
|
||||
(error) => error.code === RPC_ERRORS.unavailable,
|
||||
);
|
||||
assert.equal(store.restoreOverwrite("com.example", "sync-credential"), false);
|
||||
failEncrypt = false;
|
||||
const ref = store.getReference("com.example", "sync-credential");
|
||||
assert.equal(store.resolve("com.example", ref), "good-password");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("sync provider bindings live outside plugin secrets and reject namespace mismatches", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
});
|
||||
store.bindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
assert.throws(
|
||||
() => store.bindSyncProviderPlugin("com.example", "com.other.sync"),
|
||||
/outside the plugin namespace/,
|
||||
);
|
||||
store.set("com.attacker", "sync-provider-map:com.example.sync", "com.attacker");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
store.unbindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
// Tombstone remains so legacy map backfill cannot resurrect after disconnect.
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync")?.pluginId, "");
|
||||
// Unbind also consumes map markers (including attacker leftovers).
|
||||
assert.equal(
|
||||
database.getSecretByKey("com.attacker", "sync-provider-map:com.example.sync"),
|
||||
null,
|
||||
);
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 0);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
// Reconnect clears the tombstone.
|
||||
store.bindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("unbind deletes owner map markers and blocks legacy re-promotion", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
});
|
||||
// Credential evidence is required for map promote; simulate a real owner plus
|
||||
// an intermediate-build map row that backfill would otherwise promote.
|
||||
store.set("com.example", "sync-credential", "secret");
|
||||
store.set("com.example", "sync-provider-map:com.example.sync", "com.example");
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 1);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
assert.equal(
|
||||
database.getSecretByKey("com.example", "sync-provider-map:com.example.sync"),
|
||||
null,
|
||||
"promote consumes the map marker",
|
||||
);
|
||||
// User disconnects; reintroduce a map row as if something wrote it back.
|
||||
// Tombstone still blocks re-promotion even with credentials + map present.
|
||||
store.unbindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
store.set("com.example", "sync-provider-map:com.example.sync", "com.example");
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 0);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync")?.pluginId, "");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("failed restoreOverwrite keeps stash for retry", (context) => {
|
||||
const database = createDatabase(context);
|
||||
let failEncrypt = false;
|
||||
const safeStorage = {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value) => {
|
||||
if (failEncrypt) return Buffer.alloc(0);
|
||||
return Buffer.from(`sealed:${Buffer.from(value).toString("base64")}`);
|
||||
},
|
||||
decryptString: (value) => {
|
||||
const encoded = value.toString().slice("sealed:".length);
|
||||
return Buffer.from(encoded, "base64").toString();
|
||||
},
|
||||
};
|
||||
const store = new PluginSecretStore({ database, safeStorage });
|
||||
const first = store.set("com.example", "sync-credential", "good-password");
|
||||
store.set("com.example", "sync-credential", "bad-password", { stashPrevious: true });
|
||||
failEncrypt = true;
|
||||
assert.throws(
|
||||
() => store.restoreOverwrite("com.example", "sync-credential"),
|
||||
(error) => error.code === RPC_ERRORS.unavailable,
|
||||
);
|
||||
failEncrypt = false;
|
||||
assert.equal(store.restoreOverwrite("com.example", "sync-credential"), true);
|
||||
assert.equal(store.resolve("com.example", first), "good-password");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("existing overwrite stash is preserved across retry puts and cleared by prefix delete", (context) => {
|
||||
const database = createDatabase(context);
|
||||
let failEncrypt = false;
|
||||
const safeStorage = {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value) => {
|
||||
if (failEncrypt) return Buffer.alloc(0);
|
||||
return Buffer.from(`sealed:${Buffer.from(value).toString("base64")}`);
|
||||
},
|
||||
decryptString: (value) => {
|
||||
const encoded = value.toString().slice("sealed:".length);
|
||||
return Buffer.from(encoded, "base64").toString();
|
||||
},
|
||||
};
|
||||
const store = new PluginSecretStore({ database, safeStorage });
|
||||
const first = store.set("com.example", "sync-credential", "good-password");
|
||||
store.set("com.example", "sync-credential", "bad-password", { stashPrevious: true });
|
||||
failEncrypt = true;
|
||||
assert.throws(
|
||||
() => store.restoreOverwrite("com.example", "sync-credential"),
|
||||
(error) => error.code === RPC_ERRORS.unavailable,
|
||||
);
|
||||
failEncrypt = false;
|
||||
// Retry put must not replace the original good-password stash with bad-password.
|
||||
store.set("com.example", "sync-credential", "worse-password", { stashPrevious: true });
|
||||
assert.equal(store.restoreOverwrite("com.example", "sync-credential"), true);
|
||||
assert.equal(store.resolve("com.example", first), "good-password");
|
||||
|
||||
store.set("com.example", "sync-credential", "temp", { stashPrevious: true });
|
||||
store.deleteByKeyPrefix("com.example", "sync-credential");
|
||||
assert.equal(store.restoreOverwrite("com.example", "sync-credential"), false);
|
||||
assert.equal(store.getReference("com.example", "sync-credential"), undefined);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("resolveSyncProviderPlugin does not infer ownership from credential prefixes alone", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
});
|
||||
store.set("com.example", "sync-credential", "pw");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync"), null);
|
||||
// Without a binding (or live-provider backfill), credentials alone are not enough.
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
// Explicit bind still works for disconnect cleanup.
|
||||
store.bindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("live provider backfill seeds bindings for v2 credential-only upgrades", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
});
|
||||
// Real schema-2 path: credentials exist, binding table empty, no map rows.
|
||||
store.set("com.example", "sync-credential", "pw");
|
||||
store.set("com.disabled", "sync-credential", "pw2");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync"), null);
|
||||
// Host should pass installed manifests including disabled plugins.
|
||||
const promoted = store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.example", provider: { id: "com.example.sync" } },
|
||||
{ pluginId: "com.disabled", provider: { id: "com.disabled.sync" } },
|
||||
// No credentials under this plugin — skip.
|
||||
{ pluginId: "com.other", provider: { id: "com.other.cloud" } },
|
||||
// Wrong namespace alone would be multi with sync below — still skip evil.
|
||||
]);
|
||||
assert.equal(promoted, 2);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.disabled.sync"), "com.disabled");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.other.cloud"), undefined);
|
||||
// Idempotent when binding already exists.
|
||||
assert.equal(
|
||||
store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.example", provider: { id: "com.example.sync" } },
|
||||
]),
|
||||
0,
|
||||
);
|
||||
// Multi-provider plugins must not all bind from one shared credential row.
|
||||
store.set("com.multi", "sync-credential", "shared");
|
||||
assert.equal(
|
||||
store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.multi", provider: { id: "com.multi.old" } },
|
||||
{ pluginId: "com.multi", provider: { id: "com.multi.new" } },
|
||||
]),
|
||||
0,
|
||||
);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.multi.old"), undefined);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.multi.new"), undefined);
|
||||
// Cross-plugin claim on the same providerId must not bind the first writer.
|
||||
store.set("com.example.sync", "sync-credential", "nested");
|
||||
assert.equal(
|
||||
store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.example", provider: { id: "com.example.sync.foo" } },
|
||||
{ pluginId: "com.example.sync", provider: { id: "com.example.sync.foo" } },
|
||||
]),
|
||||
0,
|
||||
);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync.foo"), undefined);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("live provider backfill does not resurrect explicit unbind tombstones", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
});
|
||||
store.set("com.example", "sync-credential", "pw");
|
||||
store.bindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
store.unbindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync")?.pluginId, "");
|
||||
assert.equal(
|
||||
store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.example", provider: { id: "com.example.sync" } },
|
||||
]),
|
||||
0,
|
||||
);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
database.close();
|
||||
});
|
||||
Reference in New Issue
Block a user