From 837db7796dd369888bd7045fef3d4db3b1963c65 Mon Sep 17 00:00:00 2001 From: Gabriel Grasel Moura Date: Wed, 26 Aug 2026 19:01:44 -0300 Subject: [PATCH 1/2] fix(windows): grava credenciais DPAPI sem BOM UTF-8 O update() escrevia o arquivo .secure.dpapi com WriteAllText usando Encoding.UTF8, que no PowerShell 5.1 emite BOM (EF BB BF). Passa a usar UTF8Encoding($false) e valida a escrita relendo os bytes e comparando com o base64 exato. --- .../secureStorage/platformStorage.test.ts | 39 +++++++++++++++++++ .../secureStorage/windowsCredentialStorage.ts | 9 ++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/utils/secureStorage/platformStorage.test.ts b/src/utils/secureStorage/platformStorage.test.ts index 62a709debd..f8a0a40f16 100644 --- a/src/utils/secureStorage/platformStorage.test.ts +++ b/src/utils/secureStorage/platformStorage.test.ts @@ -102,6 +102,45 @@ describe("Secure Storage Platform Implementations", () => { }); }); + /** + * Issue #77 — DPAPI file must be UTF-8 without BOM. + * + * LIMIT: form-only. These tests assert the generated PowerShell script. + * Real Windows PowerShell 5.1 / .NET Framework / DPAPI is not exercisable + * on this host (macOS). + */ + describe("Windows DPAPI write encoding (issue #77)", () => { + function updateScript(): string { + windowsCredentialStorage.update(testData); + return execaCalls()[0][1][1]; + } + + function writePath(script: string): string { + const idx = script.indexOf("[System.IO.File]::WriteAllText"); + expect(idx).toBeGreaterThan(-1); + return script.slice(idx); + } + + test("WriteAllText uses UTF8Encoding($false) and not Encoding::UTF8", () => { + const write = writePath(updateScript()); + + expect(write).toContain("New-Object System.Text.UTF8Encoding($false)"); + expect(write).not.toContain("[System.Text.Encoding]::UTF8"); + expect(write).not.toMatch(/\bOut-File\b/); + expect(write).not.toMatch(/\bSet-Content\b/); + }); + + test("post-write validation re-reads bytes and FromBase64String-decodes without BOM strip", () => { + const write = writePath(updateScript()); + + expect(write).toContain("[System.IO.File]::ReadAllBytes"); + expect(write).toContain("[Convert]::FromBase64String"); + expect(write).toContain("-cne $protectedBase64"); + expect(write).toMatch(/Write-Error/); + expect(write).toMatch(/exit\s+1/); + }); + }); + describe("Windows PowerShell Escaping", () => { test("escapes single quotes and prevents $ expansion", () => { const dataWithDollar = { diff --git a/src/utils/secureStorage/windowsCredentialStorage.ts b/src/utils/secureStorage/windowsCredentialStorage.ts index f436527fef..a4d768c501 100644 --- a/src/utils/secureStorage/windowsCredentialStorage.ts +++ b/src/utils/secureStorage/windowsCredentialStorage.ts @@ -213,8 +213,15 @@ export const windowsCredentialStorage: SecureStorage = { [System.IO.File]::WriteAllText( $path, $protectedBase64, - [System.Text.Encoding]::UTF8 + (New-Object System.Text.UTF8Encoding($false)) ) + $writtenBytes = [System.IO.File]::ReadAllBytes($path) + $writtenText = [System.Text.Encoding]::ASCII.GetString($writtenBytes) + if ($writtenText -cne $protectedBase64) { + Write-Error 'DPAPI post-write validation failed: file is not exact UTF-8 base64 without BOM' + exit 1 + } + [void][Convert]::FromBase64String($writtenText) } catch { Write-Error $_.Exception.Message exit 1 From 4e476f2f7e835043a2c4e49610e36e962e80e0e5 Mon Sep 17 00:00:00 2001 From: Gabriel Grasel Moura Date: Thu, 27 Aug 2026 11:16:01 -0300 Subject: [PATCH 2/2] =?UTF-8?q?feat(auth):=20aceita=20API=20key=20Verboo?= =?UTF-8?q?=20como=20sess=C3=A3o=20no=20modo=20headless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O gate de inicialização só aceitava OAuth; uma API key vbk_ válida (já usada pelo desktop no router) não autenticava o agente headless. A chave passa a ser aceita como fallback: validada com Bearer no /router/v1/models (o /api/me não aceita API key), com OAuth continuando primário. Chave inválida gera erro específico. Termos e entitlement usam endpoints OAuth-only e são pulados nesse caminho (ver descrição do PR). --- src/services/oauth/verbooStartupAuth.test.ts | 74 +++++++++- src/services/oauth/verbooStartupAuth.ts | 136 +++++++++++++++++-- 2 files changed, 199 insertions(+), 11 deletions(-) diff --git a/src/services/oauth/verbooStartupAuth.test.ts b/src/services/oauth/verbooStartupAuth.test.ts index 26ff5fd1db..79feabfe9a 100644 --- a/src/services/oauth/verbooStartupAuth.test.ts +++ b/src/services/oauth/verbooStartupAuth.test.ts @@ -1,6 +1,13 @@ -import { expect, test } from 'bun:test' +import { afterEach, expect, test } from 'bun:test' import { getCLIEntitlementDeniedMessage } from './cliEntitlement.js' +import { + HEADLESS_UNAUTHENTICATED_MESSAGE, + VERBOO_API_KEY_INVALID_MESSAGE, + headlessSessionFailureError, + readHeadlessVerbooApiKey, + validateVerbooApiKey, +} from './verbooStartupAuth.js' test('explains each denied CLI entitlement without referring to router models', () => { expect(getCLIEntitlementDeniedMessage('past_due')).toContain( @@ -11,3 +18,68 @@ test('explains each denied CLI entitlement without referring to router models', 'assinatura Verboo Code ativa', ) }) + +const originalApiKey = process.env.ANTHROPIC_API_KEY + +afterEach(() => { + if (originalApiKey === undefined) { + delete process.env.ANTHROPIC_API_KEY + } else { + process.env.ANTHROPIC_API_KEY = originalApiKey + } +}) + +test('headless Verboo API key is read from ANTHROPIC_API_KEY when it is vbk_', () => { + process.env.ANTHROPIC_API_KEY = 'vbk_from_env_key_ok' + expect(readHeadlessVerbooApiKey()).toBe('vbk_from_env_key_ok') +}) + +test('headless Verboo API key ignores non-vbk env and uses the FD fallback', () => { + process.env.ANTHROPIC_API_KEY = 'sk-ant-not-a-verboo-key' + expect( + readHeadlessVerbooApiKey(() => 'vbk_from_fd_key_ok'), + ).toBe('vbk_from_fd_key_ok') +}) + +test('headless Verboo API key is absent when neither env nor FD is vbk_', () => { + delete process.env.ANTHROPIC_API_KEY + expect(readHeadlessVerbooApiKey(() => null)).toBeUndefined() +}) + +test('validates vbk_ against the router models endpoint, not /api/me', async () => { + const urls: string[] = [] + const result = await validateVerbooApiKey( + 'vbk_test_key_long_enough', + async (url, config) => { + urls.push(String(url)) + expect( + (config as { headers: { Authorization: string } }).headers + .Authorization, + ).toBe('Bearer vbk_test_key_long_enough') + return { status: 200, data: { data: [{ id: 'model' }] } } + }, + ) + expect(result).toBe('ok') + expect(urls[0]).toContain('/router/v1/models') + expect(urls[0]).not.toContain('/api/me') +}) + +test('a 401 from the router marks the vbk_ key unauthorized', async () => { + const result = await validateVerbooApiKey( + 'vbk_expired_or_wrong', + async () => ({ status: 401, data: { error: 'invalid or expired token' } }), + ) + expect(result).toBe('unauthorized') +}) + +test('headless failure with an invalid vbk_ is specific, not the OAuth login prompt', () => { + expect(headlessSessionFailureError({ kind: 'invalid-api-key' }).message).toBe( + VERBOO_API_KEY_INVALID_MESSAGE, + ) + expect(VERBOO_API_KEY_INVALID_MESSAGE).toBe('API key inválida ou expirada') + expect( + headlessSessionFailureError({ kind: 'unauthenticated' }).message, + ).toBe(HEADLESS_UNAUTHENTICATED_MESSAGE) + expect(HEADLESS_UNAUTHENTICATED_MESSAGE).toContain('verboo /login') + expect(HEADLESS_UNAUTHENTICATED_MESSAGE).not.toContain('API key') +}) diff --git a/src/services/oauth/verbooStartupAuth.ts b/src/services/oauth/verbooStartupAuth.ts index c519ac1bcb..22c51de5a8 100644 --- a/src/services/oauth/verbooStartupAuth.ts +++ b/src/services/oauth/verbooStartupAuth.ts @@ -4,7 +4,9 @@ import { runOAuthLoginFlow } from '../../cli/handlers/auth.js' import { getOauthConfig, isVerbooMode, + VERBOO_ROUTER_URL, } from '../../constants/oauth.js' +import { getApiKeyFromFileDescriptor } from '../../utils/authFileDescriptor.js' import { checkAndRefreshOAuthTokenIfNeeded, clearOAuthTokenCache, @@ -56,8 +58,106 @@ import { export type VerbooSessionResult = | { kind: 'ok'; tokens: OAuthTokens; refreshed: boolean } | { kind: 'unauthenticated' } + | { kind: 'invalid-api-key' } | { kind: 'degraded'; reason: string } +export const VERBOO_API_KEY_PREFIX = 'vbk_' +export const VERBOO_API_KEY_INVALID_MESSAGE = 'API key inválida ou expirada' +export const HEADLESS_UNAUTHENTICATED_MESSAGE = + 'Não autenticado no Verboo. Execute `verboo /login` em um terminal interativo antes de usar o modo headless.' + +type RouterGet = ( + url: string, + config?: { + headers?: Record + timeout?: number + validateStatus?: () => boolean + }, +) => Promise<{ status: number; data?: unknown }> + +function isVerbooApiKey(value: string | null | undefined): value is string { + return Boolean(value?.startsWith(VERBOO_API_KEY_PREFIX)) +} + +/** ANTHROPIC_API_KEY first (what the desktop injects), then the FD equivalent. */ +export function readHeadlessVerbooApiKey( + fromFd: () => string | null = getApiKeyFromFileDescriptor, +): string | undefined { + const fromEnv = process.env.ANTHROPIC_API_KEY?.trim() + if (isVerbooApiKey(fromEnv)) return fromEnv + const fd = fromFd()?.trim() + if (isVerbooApiKey(fd)) return fd + return undefined +} + +/** + * Router `/models` is the endpoint the desktop already uses with Bearer vbk_ + * (model_service.rs). `/api/me` is the OAuth account API — fake vbk_ and fake + * JWT both return the same 401, so we do not claim it accepts API keys. + */ +export async function validateVerbooApiKey( + key: string, + get: RouterGet = axios.get, +): Promise<'ok' | 'unauthorized' | 'error'> { + const endpoint = `${VERBOO_ROUTER_URL}/models` + try { + const response = await get(endpoint, { + headers: { + Authorization: `Bearer ${key}`, + 'Content-Type': 'application/json', + }, + timeout: 5_000, + validateStatus: () => true, + }) + if (response.status === 200) return 'ok' + if (response.status === 401 || response.status === 403) return 'unauthorized' + return 'error' + } catch { + return 'error' + } +} + +function apiKeySessionTokens(key: string): OAuthTokens { + return { + accessToken: key, + refreshToken: null, + expiresAt: null, + scopes: [], + subscriptionType: null, + rateLimitTier: null, + } +} + +async function sessionFromVerbooApiKey(): Promise { + const key = readHeadlessVerbooApiKey() + if (!key) return { kind: 'unauthenticated' } + const check = await validateVerbooApiKey(key) + if (check === 'ok') { + return { kind: 'ok', tokens: apiKeySessionTokens(key), refreshed: false } + } + if (check === 'unauthorized') return { kind: 'invalid-api-key' } + return { kind: 'degraded', reason: 'API key validation failed' } +} + +async function unauthenticatedOrApiKey(): Promise { + const apiKeySession = await sessionFromVerbooApiKey() + if (apiKeySession.kind !== 'unauthenticated') return apiKeySession + return { kind: 'unauthenticated' } +} + +function isApiKeySession(tokens: OAuthTokens): boolean { + return tokens.accessToken.startsWith(VERBOO_API_KEY_PREFIX) +} + +export function headlessSessionFailureError( + session: { kind: 'invalid-api-key' | 'unauthenticated' }, +): Error { + if (session.kind === 'invalid-api-key') { + return new Error(VERBOO_API_KEY_INVALID_MESSAGE) + } + return new Error(HEADLESS_UNAUTHENTICATED_MESSAGE) +} + export type VerbooLoginPreflightResult = | { kind: 'ready' @@ -146,7 +246,9 @@ export async function validateVerbooSession(): Promise { const tokens = await getClaudeAIOAuthTokensAsync() if (!tokens?.accessToken) { - return { kind: 'unauthenticated' } + // OAuth is primary; vbk_ is the headless fallback (desktop injects it + // as ANTHROPIC_API_KEY). + return sessionFromVerbooApiKey() } let result = await callApiMe(tokens.accessToken) @@ -158,10 +260,10 @@ export async function validateVerbooSession(): Promise { if (!didOAuthRefreshRecover(outcome)) { return outcome === 'transient_error' ? { kind: 'degraded', reason: 'temporary OAuth refresh failure' } - : { kind: 'unauthenticated' } + : unauthenticatedOrApiKey() } const refreshed = await getClaudeAIOAuthTokensAsync() - if (!refreshed?.accessToken) return { kind: 'unauthenticated' } + if (!refreshed?.accessToken) return unauthenticatedOrApiKey() result = await callApiMe(refreshed.accessToken) if (result.status === 'ok' && result.data) { persistAccount(result.data) @@ -170,7 +272,7 @@ export async function validateVerbooSession(): Promise { } catch (err) { logError(err as Error) } - return { kind: 'unauthenticated' } + return unauthenticatedOrApiKey() } if (result.status === 'ok') { @@ -184,7 +286,7 @@ export async function validateVerbooSession(): Promise { } if (result.status === 'unauthorized') { - return { kind: 'unauthenticated' } + return unauthenticatedOrApiKey() } // Erro de rede / 5xx: deixar passar com warning para não bloquear startup @@ -341,9 +443,19 @@ export async function preflightVerbooLogin(): Promise