From b6232f13f4fcb653c7b77dc7bb368bf6503d569e Mon Sep 17 00:00:00 2001 From: Savas Neto Date: Sat, 19 Sep 2026 12:52:17 -0300 Subject: [PATCH 1/2] fix secure storage corruption on Linux keyrings --- src/utils/auth.ts | 8 +++- .../secureStorage/fallbackStorage.test.ts | 19 ++++++++ src/utils/secureStorage/fallbackStorage.ts | 9 ++-- src/utils/secureStorage/index.ts | 13 +++++- src/utils/secureStorage/linuxSecretStorage.ts | 35 +++++++++++++-- .../secureStorage/platformStorage.test.ts | 44 ++++++++++++++++++- 6 files changed, 118 insertions(+), 10 deletions(-) diff --git a/src/utils/auth.ts b/src/utils/auth.ts index af4724571e..b2500897e6 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -1266,7 +1266,13 @@ export function saveOAuthTokensIfNeeded(tokens: OAuthTokens): { }, } - const updateStatus = secureStorage.update(storageData) + // A malformed native record cannot be read to preserve provider accounts, + // but a successful OAuth login is an explicit recovery boundary: replace + // the unreadable record with the freshly authenticated session. Other + // storage failures remain fail-closed. + const updateStatus = secureStorage.update(storageData, { + replaceCorrupt: true, + }) if (updateStatus.success) { logEvent('tengu_oauth_tokens_saved', { storageBackend }) diff --git a/src/utils/secureStorage/fallbackStorage.test.ts b/src/utils/secureStorage/fallbackStorage.test.ts index 424a52ccbe..3f88c536f5 100644 --- a/src/utils/secureStorage/fallbackStorage.test.ts +++ b/src/utils/secureStorage/fallbackStorage.test.ts @@ -30,4 +30,23 @@ describe('fallback secure storage classification', () => { const secondary = storage(() => ({ kind: 'ok', data })) expect(createFallbackStorage(primary, secondary).readResult?.()).toEqual({ kind: 'ok', data }) }) + + test('passes the corrupt-record recovery option to the native vault', () => { + let receivedOptions: unknown + const primary: SecureStorage = { + ...storage(() => ({ kind: 'error', warning: 'Secret Service returned malformed JSON.' })), + update: (_data, options) => { + receivedOptions = options + return { success: true } + }, + } + const secondary = storage(() => ({ kind: 'missing' })) + + const result = createFallbackStorage(primary, secondary).update(data, { + replaceCorrupt: true, + }) + + expect(result.success).toBe(true) + expect(receivedOptions).toEqual({ replaceCorrupt: true }) + }) }) diff --git a/src/utils/secureStorage/fallbackStorage.ts b/src/utils/secureStorage/fallbackStorage.ts index 7fc2f716b7..e3f89e128a 100644 --- a/src/utils/secureStorage/fallbackStorage.ts +++ b/src/utils/secureStorage/fallbackStorage.ts @@ -55,11 +55,14 @@ export function createFallbackStorage( if (fallback) return { kind: 'ok', data: fallback } return result.kind === 'error' ? result : { kind: 'missing' } }, - update(data: SecureStorageData): { success: boolean; warning?: string } { + update( + data: SecureStorageData, + options?: { preserveProviderAccounts?: boolean; lockHeld?: boolean; replaceCorrupt?: boolean }, + ): { success: boolean; warning?: string } { // Capture state before update const primaryDataBefore = primary.read() - const result = primary.update(data) + const result = primary.update(data, options) if (result.success) { // Delete secondary when migrating to primary for the first time @@ -71,7 +74,7 @@ export function createFallbackStorage( return result } - const fallbackResult = secondary.update(data) + const fallbackResult = secondary.update(data, options) if (fallbackResult.success) { // Primary write failed but primary may still hold an *older* valid diff --git a/src/utils/secureStorage/index.ts b/src/utils/secureStorage/index.ts index fabf76ffe0..b9ec22847b 100644 --- a/src/utils/secureStorage/index.ts +++ b/src/utils/secureStorage/index.ts @@ -81,7 +81,12 @@ export interface SecureStorage { readResultAsync?(): Promise update( data: SecureStorageData, - options?: { preserveProviderAccounts?: boolean; lockHeld?: boolean }, + options?: { + preserveProviderAccounts?: boolean + lockHeld?: boolean + /** Replace a record that is known to contain malformed JSON. */ + replaceCorrupt?: boolean + }, ): { success: boolean; warning?: string } delete(): boolean } @@ -145,7 +150,11 @@ function preserveProviderAccountsOnSharedWrites(storage: SecureStorage): SecureS let next = data if (options.preserveProviderAccounts !== false) { const current = storage.readResult?.() - if (current?.kind === 'error') { + if ( + current?.kind === 'error' && + !(options.replaceCorrupt && + current.warning?.includes('returned malformed JSON.')) + ) { return { success: false, warning: current.warning ?? 'Secure storage read failed.' } } if (current?.kind === 'ok' && current.data.providerAccounts) { diff --git a/src/utils/secureStorage/linuxSecretStorage.ts b/src/utils/secureStorage/linuxSecretStorage.ts index 5576f7bec0..ffb2315c39 100644 --- a/src/utils/secureStorage/linuxSecretStorage.ts +++ b/src/utils/secureStorage/linuxSecretStorage.ts @@ -1,4 +1,5 @@ import { execaSync } from 'execa' +import { deflateRawSync, inflateRawSync } from 'node:zlib' import { jsonParse, jsonStringify } from '../slowOperations.js' import { CREDENTIALS_SERVICE_SUFFIX, @@ -7,6 +8,28 @@ import { } from './macOsKeychainHelpers.js' import type { SecureStorage, SecureStorageData, SecureStorageReadResult } from './index.js' +// KWallet's Secret Service adapter accepts writes larger than this but +// truncates the stored secret at 8192 bytes while still returning success. +// Store a compact, versioned representation so the shared credentials record +// remains safe as provider accounts accumulate. +const KWalletSecretLimitBytes = 8192 +const COMPRESSED_PAYLOAD_PREFIX = 'verboo-secure-v1:' + +function encodePayload(data: SecureStorageData): string { + const json = jsonStringify(data) + return `${COMPRESSED_PAYLOAD_PREFIX}${deflateRawSync(Buffer.from(json, 'utf8')).toString('base64')}` +} + +function decodePayload(payload: string): SecureStorageData { + if (!payload.startsWith(COMPRESSED_PAYLOAD_PREFIX)) { + return jsonParse(payload) + } + + const encoded = payload.slice(COMPRESSED_PAYLOAD_PREFIX.length) + const json = inflateRawSync(Buffer.from(encoded, 'base64')).toString('utf8') + return jsonParse(json) +} + /** * Linux-specific secure storage implementation using the secret-tool CLI. * secret-tool interacts with the Secret Service API (GNOME Keyring, KWallet, etc.). @@ -27,7 +50,7 @@ export const linuxSecretStorage: SecureStorage = { ) if (result.exitCode === 0 && result.stdout) { - return jsonParse(result.stdout) + return decodePayload(result.stdout) } } catch { // fall through @@ -47,7 +70,7 @@ export const linuxSecretStorage: SecureStorage = { ) if (result.exitCode === 0 && result.stdout) { try { - return { kind: 'ok', data: jsonParse(result.stdout) } + return { kind: 'ok', data: decodePayload(result.stdout) } } catch { return { kind: 'error', warning: 'Secret Service returned malformed JSON.' } } @@ -70,7 +93,13 @@ export const linuxSecretStorage: SecureStorage = { const serviceName = getSecureStorageServiceName( CREDENTIALS_SERVICE_SUFFIX, ) - const payload = jsonStringify(data) + const payload = encodePayload(data) + if (Buffer.byteLength(payload, 'utf8') > KWalletSecretLimitBytes) { + return { + success: false, + warning: 'Secure Service payload exceeds the Linux keyring limit.', + } + } // secret-tool store --label=[label] service [service] account [account] // The payload is passed via stdin const result = execaSync( diff --git a/src/utils/secureStorage/platformStorage.test.ts b/src/utils/secureStorage/platformStorage.test.ts index 9be7e29051..122957067b 100644 --- a/src/utils/secureStorage/platformStorage.test.ts +++ b/src/utils/secureStorage/platformStorage.test.ts @@ -1,6 +1,8 @@ import { expect, test, mock, describe, beforeEach, afterEach } from "bun:test"; +import { randomBytes } from 'node:crypto'; import * as fs from 'node:fs'; +import { deflateRawSync, inflateRawSync } from 'node:zlib'; import { linuxSecretStorage } from "./linuxSecretStorage.js"; import { windowsCredentialStorage } from "./windowsCredentialStorage.js"; import { macOsKeychainStorage } from "./macOsKeychainStorage.js"; @@ -95,6 +97,14 @@ describe("Secure Storage Platform Implementations", () => { expect(linuxSecretStorage.readResult?.()).toEqual({ kind: "missing" }); }); + test("Linux classified reads identify malformed JSON", () => { + mockExecaSync.mockReturnValue({ exitCode: 0, stdout: "legacy-token", stderr: "" }); + expect(linuxSecretStorage.readResult?.()).toEqual({ + kind: "error", + warning: "Secret Service returned malformed JSON.", + }); + }); + test("Windows classified reads distinguish a missing DPAPI file", () => { encryptedFile = Object.assign(new Error('Missing'), { code: 'ENOENT' }); expect(windowsCredentialStorage.readResult?.()).toEqual({ kind: "missing" }); @@ -394,7 +404,10 @@ describe("Secure Storage Platform Implementations", () => { linuxSecretStorage.update(testData); const options = execaCalls()[0][2]; - expect(options.input).toContain("secret-token"); + expect(options.input?.startsWith("verboo-secure-v1:")).toBe(true); + const encoded = options.input!.slice("verboo-secure-v1:".length); + const decoded = inflateRawSync(Buffer.from(encoded, 'base64')).toString('utf8'); + expect(decoded).toContain("secret-token"); }); test("read parses stdout", () => { @@ -403,6 +416,35 @@ describe("Secure Storage Platform Implementations", () => { expect(result).toEqual(testData); }); + + test("read parses the compressed payload written for KWallet", () => { + const json = JSON.stringify(testData); + const payload = `verboo-secure-v1:${deflateRawSync(Buffer.from(json)).toString('base64')}`; + mockExecaSync.mockReturnValue({ exitCode: 0, stdout: payload }); + + expect(linuxSecretStorage.read()).toEqual(testData); + }); + + test("rejects a compressed payload that exceeds KWallet's limit", () => { + const oversized = { + mcpOAuth: { + server: { + accessToken: randomBytes(20_000).toString('base64'), + expiresAt: 1, + serverName: 'server', + serverUrl: 'https://example.invalid', + }, + }, + }; + + const result = linuxSecretStorage.update(oversized); + + expect(result).toEqual({ + success: false, + warning: 'Secure Service payload exceeds the Linux keyring limit.', + }); + expect(mockExecaSync).not.toHaveBeenCalled(); + }); }); describe("Platform Selection", () => { From 7e04d58b0e249e990d3f12e1139ec42704be5570 Mon Sep 17 00:00:00 2001 From: Savas Neto Date: Sat, 19 Sep 2026 13:09:06 -0300 Subject: [PATCH 2/2] chore: release Verboo Code 0.15.27 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ab76228d93..c8027866bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@verboo/code", - "version": "0.15.26", + "version": "0.15.27", "description": "Verboo Code — coding agent for the Verboo platform", "type": "module", "bin": {