From 200e5cec80f2f81c2a475d045b3edc020fdfcf1c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 17 Jun 2026 05:35:02 -0400 Subject: [PATCH] test(storage): assert credentials never persist in plaintext at rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-launch credential-security regression: scans the ENTIRE serialized account record (meta + encrypted blob) for the secret/access-key strings and asserts they never appear in plaintext, that listConnections() doesn't expose them, that they round-trip while unlocked, and that they're unreachable once locked. The prior check only compared the cipher field to JSON.stringify(payload), which wouldn't catch a secret leaking into plaintext meta. Audit conclusion: credential storage is sound — envelope encryption (AES-GCM-256 payload, fresh per-encrypt IV; master key encrypted to-self via the Nostr signer, NIP-44 preferred / NIP-04 fallback), in-memory master key cleared on logout, no secret logging, legacy plaintext migrated then cleared. Co-Authored-By: Claude Opus 4.8 --- tests/storage-service.test.mjs | 57 ++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/storage-service.test.mjs b/tests/storage-service.test.mjs index d1d69999..10322541 100644 --- a/tests/storage-service.test.mjs +++ b/tests/storage-service.test.mjs @@ -220,4 +220,61 @@ describe("StorageService", () => { assert.strictEqual(c1.meta.defaultForUploads, false); assert.strictEqual(c2.meta.defaultForUploads, true); }); + + // Scenario (SCN-secrets-never-at-rest-plaintext): + // Pre-launch credential-security invariant — a user's S3/R2 secret (and + // access key id) must NEVER be persisted in plaintext anywhere in the stored + // record (not in the encrypted payload, not leaked into plaintext meta), and + // listConnections() (the unauthenticated/metadata view) must not expose them. + // Cheat-resistant: scans the ENTIRE serialized at-rest record for the secret + // strings, so passing requires real encryption, not a narrower field check. + test("secrets never appear in plaintext anywhere in the at-rest record", async () => { + await storageService.unlock(pubkey, { signer: mockSigner }); + + const SECRET = "S3CR3T-do-not-persist-7f3a9c"; + const ACCESS_KEY = "AKIA-PUBLIC-ID-do-not-persist-1b2c3d"; + + await storageService.saveConnection( + pubkey, + "conn_secret", + { + provider: PROVIDERS.R2, + accessKeyId: ACCESS_KEY, + secretAccessKey: SECRET, + accountId: "acct-123", + }, + { label: "My R2", bucket: "my-bucket", publicBaseUrl: "https://cdn.example" }, + ); + + // Serialize the WHOLE persisted account (meta + encrypted blob + everything). + const account = await storageService._getAccount(pubkey); + const atRest = JSON.stringify(account); + + assert.ok( + !atRest.includes(SECRET), + "secretAccessKey must never be stored in plaintext at rest", + ); + assert.ok( + !atRest.includes(ACCESS_KEY), + "accessKeyId must never be stored in plaintext at rest", + ); + // The encrypted payload must actually exist (encryption happened). + assert.ok(account.connections.conn_secret.encrypted.cipher); + + // The metadata view must not expose secrets either. + const listed = JSON.stringify(await storageService.listConnections(pubkey)); + assert.ok(!listed.includes(SECRET) && !listed.includes(ACCESS_KEY), + "listConnections() must not expose secrets"); + + // Sanity: round-trips correctly while unlocked. + const got = await storageService.getConnection(pubkey, "conn_secret"); + assert.strictEqual(got.secretAccessKey, SECRET); + + // And once locked, the plaintext secret is no longer reachable. + storageService.lock(pubkey); + await assert.rejects( + () => storageService.getConnection(pubkey, "conn_secret"), + /Storage is locked/, + ); + }); });