diff --git a/bun.lock b/bun.lock index 986923f8..6605e436 100644 --- a/bun.lock +++ b/bun.lock @@ -19,9 +19,6 @@ "lint-staged": "^16.2.7", "typescript": "^5.9.3", }, - "peerDependencies": { - "typescript": "^5.9.3", - }, }, }, "packages": { diff --git a/docs/implementation/auth.md b/docs/implementation/auth.md index 1c02ca35..d5d29865 100644 --- a/docs/implementation/auth.md +++ b/docs/implementation/auth.md @@ -65,6 +65,22 @@ Each credential is a separate keychain entry using service name `"githits"`: The `v1:` prefix allows future key format changes without collisions. +#### Windows chunked storage + +Windows Credential Manager limits credential blobs to `CRED_MAX_CREDENTIAL_BLOB_SIZE` (2560 **bytes**). The `@napi-rs/keyring` binding encodes passwords as UTF-16 (2 bytes per character), so the effective character limit is 1280. Since JSON-serialized token data (especially JWT access tokens) can exceed this, the CLI wraps the `KeyringService` with a `ChunkingKeyringService` decorator on Windows (`process.platform === "win32"`). This decorator is not applied on macOS or Linux, which have no practical per-entry size limits. + +When a value exceeds `WINDOWS_MAX_ENTRY_SIZE` (1200 characters — a conservative threshold providing 80-char margin from the 1280 limit), the decorator splits it across multiple keyring entries. The chunk size is configurable via the `ChunkingKeyringService` constructor, so the same decorator can be reused if other platforms have different limits: + +| Account key pattern | Content | +|---|---| +| `` | Sentinel: `CHUNKED::` | +| `:chunk::0` | First chunk of the JSON value | +| `:chunk::N` | Nth chunk of the JSON value | + +Each write uses a unique `writeId` to namespace chunk keys. This ensures atomicity: new chunks are written before the sentinel is updated, so a crash at any point leaves valid data. Old chunks are cleaned up after the sentinel is committed. + +Values under 1200 characters are stored directly with no sentinel, maintaining full backward compatibility with pre-chunking CLI versions. If a user downgrades the CLI after tokens were stored as chunks, the old CLI reads the sentinel as raw text, fails JSON parsing (via `parseJsonOrNull`), and prompts re-login. The same applies to chunked client registrations, which would trigger re-registration. Both are acceptable graceful degradation. + The `getStorageLocation()` method returns a platform-specific label: "macOS Keychain (githits)" on macOS, "Windows Credential Manager (githits)" on Windows, and "System keychain (githits)" on Linux. ### File storage (fallback) @@ -93,7 +109,9 @@ Keychain write must succeed before the file entry is deleted. Tokens and client ``` Container (createAuthStorage) └─ MigratingAuthStorage (decorator) - ├─ KeychainAuthStorage (primary) ← uses KeyringService + ├─ KeychainAuthStorage (primary) + │ └─ ChunkingKeyringService (Windows only, decorator) + │ └─ KeyringServiceImpl ← @napi-rs/keyring └─ AuthStorageImpl (legacy) ← file-based ``` @@ -129,6 +147,7 @@ The `hasValidToken` flag is checked by `requireAuth()` in `src/commands/mcp.ts` - **Token refresh fails silently** — By design. The container clears stale auth and `hasValidToken` becomes false, prompting re-login. - **Clearing auth** — Run `githits logout` to remove stored tokens and client registration for the current environment. - **Keychain unavailable warning** — If the system keychain is not accessible (headless Linux, CI), the CLI falls back to file storage in `~/.githits/` and prints a warning to stderr. +- **Windows "password encoded as UTF-16 is longer than platform limit"** — The Windows Credential Manager limits credential blobs to 2560 bytes (`CRED_MAX_CREDENTIAL_BLOB_SIZE`). Since passwords are stored as UTF-16 (2 bytes per char), the effective limit is 1280 characters. The `ChunkingKeyringService` decorator handles this automatically by splitting large values across multiple entries. If this error occurs on an older CLI version, upgrade to get chunked storage support. ## Key Reference Files @@ -142,6 +161,7 @@ The `hasValidToken` flag is checked by `requireAuth()` in `src/commands/mcp.ts` | `src/services/auth-service.ts` | OAuth operations (DCR, PKCE, token exchange, callback server) | | `src/services/auth-storage.ts` | `AuthStorage` interface and file-based implementation | | `src/services/keyring-service.ts` | `KeyringService` interface wrapping `@napi-rs/keyring` | +| `src/services/chunking-keyring-service.ts` | `KeyringService` decorator for chunked storage (Windows 2560-char limit) | | `src/services/keychain-auth-storage.ts` | `AuthStorage` implementation backed by system keychain | | `src/services/migrating-auth-storage.ts` | Migration decorator (keychain primary + file legacy) | | `src/services/filesystem-service.ts` | File system abstraction for testable storage | diff --git a/package.json b/package.json index 1fb0c787..c891143e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "githits", "description": "CLI companion for GitHits - code examples from global open source for developers and AI assistants", - "version": "0.1.0", + "version": "0.1.1", "type": "module", "files": [ "dist", diff --git a/src/container.ts b/src/container.ts index 7ea6fc42..7f807f0b 100644 --- a/src/container.ts +++ b/src/container.ts @@ -6,6 +6,7 @@ import { AuthStorageImpl, type BrowserService, BrowserServiceImpl, + ChunkingKeyringService, type FileSystemService, FileSystemServiceImpl, GitHitsServiceImpl, @@ -18,6 +19,7 @@ import { MigratingAuthStorage, RefreshingGitHitsService, TokenManager, + WINDOWS_MAX_ENTRY_SIZE, } from "./services/index.js"; /** @@ -29,9 +31,16 @@ function createAuthStorage(fileSystemService: FileSystemService): AuthStorage { const fileStorage = new AuthStorageImpl(fileSystemService); try { - const keyring = new KeyringServiceImpl(); + const rawKeyring = new KeyringServiceImpl(); + // Windows Credential Manager limits entries to 2560 UTF-16 chars. + // Wrap with chunking decorator to split large values across multiple entries. + const keyring = + process.platform === "win32" + ? new ChunkingKeyringService(rawKeyring, WINDOWS_MAX_ENTRY_SIZE) + : rawKeyring; // Probe keychain availability with a write+delete cycle. // Use timestamp + random suffix to avoid probe key collisions. + // Probe value "probe" is 5 chars, passes through the chunking wrapper unchanged. const probeKey = `__probe_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; keyring.setPassword("githits", probeKey, "probe"); try { diff --git a/src/services/chunking-keyring-service.test.ts b/src/services/chunking-keyring-service.test.ts new file mode 100644 index 00000000..09893342 --- /dev/null +++ b/src/services/chunking-keyring-service.test.ts @@ -0,0 +1,433 @@ +import { describe, expect, it, mock } from "bun:test"; +import { + ChunkingKeyringService, + chunkKey, + generateWriteId, + parseChunkedSentinel, + splitIntoChunks, +} from "./chunking-keyring-service.js"; +import type { KeyringService } from "./keyring-service.js"; +import { createMockKeyringService } from "./test-helpers.js"; + +/** + * In-memory KeyringService for integration-style tests where + * getPassword must reflect prior setPassword calls. + * Uses composite key (service + account) for correctness. + */ +function createInMemoryKeyring(): KeyringService { + const store = new Map(); + const compositeKey = (service: string, account: string): string => + `${service}::${account}`; + return { + getPassword: (service: string, account: string) => + store.get(compositeKey(service, account)) ?? null, + setPassword: (service: string, account: string, password: string) => { + store.set(compositeKey(service, account), password); + }, + deletePassword: (service: string, account: string) => + store.delete(compositeKey(service, account)), + }; +} + +describe("pure helpers", () => { + describe("splitIntoChunks", () => { + it("splits a string into chunks of maxSize", () => { + const result = splitIntoChunks("abcdefghij", 3); + expect(result).toEqual(["abc", "def", "ghi", "j"]); + }); + + it("returns single-element array for string under limit", () => { + const result = splitIntoChunks("short", 100); + expect(result).toEqual(["short"]); + }); + + it("returns single chunk for string exactly at limit", () => { + const value = "a".repeat(10); + const result = splitIntoChunks(value, 10); + expect(result).toEqual([value]); + }); + + it("returns single-element array with empty string for empty input", () => { + const result = splitIntoChunks("", 10); + expect(result).toEqual([""]); + }); + }); + + describe("parseChunkedSentinel", () => { + it("extracts writeId and count from valid sentinel", () => { + const result = parseChunkedSentinel("CHUNKED:abc123:3"); + expect(result).toEqual({ writeId: "abc123", count: 3 }); + }); + + it("returns null for non-sentinel string", () => { + expect(parseChunkedSentinel('{"accessToken":"abc"}')).toBeNull(); + }); + + it("returns null for sentinel missing count", () => { + expect(parseChunkedSentinel("CHUNKED:abc")).toBeNull(); + }); + + it("returns null for sentinel with non-numeric count", () => { + expect(parseChunkedSentinel("CHUNKED:abc:xyz")).toBeNull(); + }); + + it("returns null for sentinel with zero count", () => { + expect(parseChunkedSentinel("CHUNKED:abc:0")).toBeNull(); + }); + + it("returns null for sentinel with negative count", () => { + expect(parseChunkedSentinel("CHUNKED:abc:-1")).toBeNull(); + }); + + it("returns null for sentinel with empty writeId", () => { + expect(parseChunkedSentinel("CHUNKED::3")).toBeNull(); + }); + }); + + describe("chunkKey", () => { + it("generates correct key format", () => { + expect(chunkKey("v1:tokens:https://mcp.githits.com", "abc123", 2)).toBe( + "v1:tokens:https://mcp.githits.com:chunk:abc123:2", + ); + }); + }); + + describe("generateWriteId", () => { + it("returns a 6-character alphanumeric string", () => { + const id = generateWriteId(); + expect(id).toHaveLength(6); + expect(id).toMatch(/^[a-z0-9]+$/); + }); + + it("generates different values on successive calls", () => { + const ids = new Set(Array.from({ length: 10 }, () => generateWriteId())); + expect(ids.size).toBeGreaterThan(1); + }); + }); +}); + +describe("ChunkingKeyringService", () => { + const SERVICE = "githits"; + const ACCOUNT = "v1:tokens:https://mcp.githits.com"; + + describe("getPassword", () => { + it("returns null when inner returns null", () => { + const inner = createMockKeyringService(); + const chunking = new ChunkingKeyringService(inner); + + expect(chunking.getPassword(SERVICE, ACCOUNT)).toBeNull(); + }); + + it("returns value directly when not chunked", () => { + const json = '{"accessToken":"abc"}'; + const inner = createMockKeyringService({ + getPassword: mock(() => json), + }); + const chunking = new ChunkingKeyringService(inner); + + expect(chunking.getPassword(SERVICE, ACCOUNT)).toBe(json); + }); + + it("reassembles chunked value from multiple entries", () => { + const store = new Map(); + store.set(ACCOUNT, "CHUNKED:wid123:3"); + store.set(`${ACCOUNT}:chunk:wid123:0`, "aaa"); + store.set(`${ACCOUNT}:chunk:wid123:1`, "bbb"); + store.set(`${ACCOUNT}:chunk:wid123:2`, "ccc"); + const inner = createMockKeyringService({ + getPassword: mock( + (_s: string, account: string) => store.get(account) ?? null, + ), + }); + const chunking = new ChunkingKeyringService(inner); + + expect(chunking.getPassword(SERVICE, ACCOUNT)).toBe("aaabbbccc"); + }); + + it("returns null when a chunk is missing", () => { + const store = new Map(); + store.set(ACCOUNT, "CHUNKED:wid123:2"); + store.set(`${ACCOUNT}:chunk:wid123:0`, "aaa"); + // chunk:1 missing + const inner = createMockKeyringService({ + getPassword: mock( + (_s: string, account: string) => store.get(account) ?? null, + ), + }); + const chunking = new ChunkingKeyringService(inner); + + const originalError = console.error; + console.error = mock(() => {}); + try { + const result = chunking.getPassword(SERVICE, ACCOUNT); + expect(result).toBeNull(); + } finally { + console.error = originalError; + } + }); + + it("logs warning when a chunk is missing", () => { + const store = new Map(); + store.set(ACCOUNT, "CHUNKED:wid123:2"); + store.set(`${ACCOUNT}:chunk:wid123:0`, "aaa"); + const inner = createMockKeyringService({ + getPassword: mock( + (_s: string, account: string) => store.get(account) ?? null, + ), + }); + const chunking = new ChunkingKeyringService(inner); + + const errorFn = mock(() => {}); + const originalError = console.error; + console.error = errorFn; + try { + chunking.getPassword(SERVICE, ACCOUNT); + } finally { + console.error = originalError; + } + + expect(errorFn).toHaveBeenCalledTimes(1); + const firstCallArg = (errorFn.mock.calls as unknown[][])[0]?.[0]; + expect(firstCallArg).toContain("Incomplete chunked"); + }); + + it("returns null for invalid sentinel format", () => { + const inner = createMockKeyringService({ + getPassword: mock(() => "CHUNKED:badformat"), + }); + const chunking = new ChunkingKeyringService(inner); + + expect(chunking.getPassword(SERVICE, ACCOUNT)).toBeNull(); + }); + }); + + describe("setPassword", () => { + it("stores directly when value fits within limit", () => { + const inner = createInMemoryKeyring(); + const chunking = new ChunkingKeyringService(inner, 100); + + chunking.setPassword(SERVICE, ACCOUNT, "small-value"); + + expect(inner.getPassword(SERVICE, ACCOUNT)).toBe("small-value"); + }); + + it("chunks large values into multiple entries", () => { + const inner = createInMemoryKeyring(); + const chunking = new ChunkingKeyringService(inner, 10); + const value = "a".repeat(25); + + chunking.setPassword(SERVICE, ACCOUNT, value); + + const sentinel = inner.getPassword(SERVICE, ACCOUNT); + expect(sentinel).toMatch(/^CHUNKED:[a-z0-9]+:3$/); + + // Parse sentinel to find writeId and verify chunks + const parsed = parseChunkedSentinel(sentinel!); + expect(parsed).not.toBeNull(); + expect( + inner.getPassword(SERVICE, chunkKey(ACCOUNT, parsed!.writeId, 0)), + ).toBe("a".repeat(10)); + expect( + inner.getPassword(SERVICE, chunkKey(ACCOUNT, parsed!.writeId, 1)), + ).toBe("a".repeat(10)); + expect( + inner.getPassword(SERVICE, chunkKey(ACCOUNT, parsed!.writeId, 2)), + ).toBe("a".repeat(5)); + }); + + it("cleans up old chunks when replacing chunked value with small value", () => { + const inner = createInMemoryKeyring(); + const chunking = new ChunkingKeyringService(inner, 10); + + // Write a chunked value + chunking.setPassword(SERVICE, ACCOUNT, "a".repeat(25)); + const oldSentinel = parseChunkedSentinel( + inner.getPassword(SERVICE, ACCOUNT)!, + ); + expect(oldSentinel).not.toBeNull(); + + // Overwrite with a small value + chunking.setPassword(SERVICE, ACCOUNT, "tiny"); + + expect(inner.getPassword(SERVICE, ACCOUNT)).toBe("tiny"); + // Old chunks should be cleaned up + for (let i = 0; i < oldSentinel!.count; i++) { + expect( + inner.getPassword( + SERVICE, + chunkKey(ACCOUNT, oldSentinel!.writeId, i), + ), + ).toBeNull(); + } + }); + + it("cleans up old chunks when replacing with new chunked value", () => { + const inner = createInMemoryKeyring(); + const chunking = new ChunkingKeyringService(inner, 10); + + // Write 3-chunk value + chunking.setPassword(SERVICE, ACCOUNT, "a".repeat(25)); + const oldSentinel = parseChunkedSentinel( + inner.getPassword(SERVICE, ACCOUNT)!, + ); + + // Overwrite with 2-chunk value + chunking.setPassword(SERVICE, ACCOUNT, "b".repeat(15)); + const newSentinel = parseChunkedSentinel( + inner.getPassword(SERVICE, ACCOUNT)!, + ); + expect(newSentinel!.count).toBe(2); + + // Old chunks should be cleaned up + for (let i = 0; i < oldSentinel!.count; i++) { + expect( + inner.getPassword( + SERVICE, + chunkKey(ACCOUNT, oldSentinel!.writeId, i), + ), + ).toBeNull(); + } + // New chunks should exist + expect( + inner.getPassword(SERVICE, chunkKey(ACCOUNT, newSentinel!.writeId, 0)), + ).toBe("b".repeat(10)); + expect( + inner.getPassword(SERVICE, chunkKey(ACCOUNT, newSentinel!.writeId, 1)), + ).toBe("b".repeat(5)); + }); + + it("throws when chunk count exceeds maximum", () => { + const inner = createInMemoryKeyring(); + // maxEntrySize=1 with a 101-char string would need 101 chunks + const chunking = new ChunkingKeyringService(inner, 1); + + expect(() => + chunking.setPassword(SERVICE, ACCOUNT, "x".repeat(101)), + ).toThrow(/exceeding maximum of 100/); + }); + + it("uses different writeId per call", () => { + const inner = createInMemoryKeyring(); + const chunking = new ChunkingKeyringService(inner, 10); + + chunking.setPassword(SERVICE, ACCOUNT, "a".repeat(15)); + const first = parseChunkedSentinel(inner.getPassword(SERVICE, ACCOUNT)!); + + chunking.setPassword(SERVICE, ACCOUNT, "b".repeat(15)); + const second = parseChunkedSentinel(inner.getPassword(SERVICE, ACCOUNT)!); + + expect(first!.writeId).not.toBe(second!.writeId); + }); + + it("preserves old value when chunk write fails mid-write", () => { + const inner = createInMemoryKeyring(); + const chunking = new ChunkingKeyringService(inner, 10); + + // Establish a known initial small value + chunking.setPassword(SERVICE, ACCOUNT, "old-value"); + expect(inner.getPassword(SERVICE, ACCOUNT)).toBe("old-value"); + + // Create a faulty wrapper that throws on the 2nd setPassword call + // (1st call reads old sentinel via getPassword, so 2nd call is + // the first chunk write) + let writeCount = 0; + const originalSet = inner.setPassword.bind(inner); + inner.setPassword = ( + service: string, + account: string, + password: string, + ) => { + writeCount++; + if (writeCount === 2) throw new Error("simulated disk full"); + originalSet(service, account, password); + }; + + // The chunked write should throw + expect(() => + chunking.setPassword(SERVICE, ACCOUNT, "x".repeat(25)), + ).toThrow("simulated disk full"); + + // Old value should still be readable (sentinel was never written) + // Restore original setPassword for the read + inner.setPassword = originalSet; + expect(chunking.getPassword(SERVICE, ACCOUNT)).toBe("old-value"); + }); + }); + + describe("deletePassword", () => { + it("deletes non-chunked value", () => { + const inner = createInMemoryKeyring(); + const chunking = new ChunkingKeyringService(inner, 100); + chunking.setPassword(SERVICE, ACCOUNT, "small"); + + const result = chunking.deletePassword(SERVICE, ACCOUNT); + + expect(result).toBe(true); + expect(inner.getPassword(SERVICE, ACCOUNT)).toBeNull(); + }); + + it("deletes chunked value and all its chunks", () => { + const inner = createInMemoryKeyring(); + const chunking = new ChunkingKeyringService(inner, 10); + chunking.setPassword(SERVICE, ACCOUNT, "a".repeat(25)); + const sentinel = parseChunkedSentinel( + inner.getPassword(SERVICE, ACCOUNT)!, + ); + + const result = chunking.deletePassword(SERVICE, ACCOUNT); + + expect(result).toBe(true); + expect(inner.getPassword(SERVICE, ACCOUNT)).toBeNull(); + for (let i = 0; i < sentinel!.count; i++) { + expect( + inner.getPassword(SERVICE, chunkKey(ACCOUNT, sentinel!.writeId, i)), + ).toBeNull(); + } + }); + + it("returns false when main key does not exist", () => { + const inner = createInMemoryKeyring(); + const chunking = new ChunkingKeyringService(inner); + + expect(chunking.deletePassword(SERVICE, ACCOUNT)).toBe(false); + }); + }); + + describe("edge cases", () => { + it("does not chunk value exactly at MAX_CHUNK_SIZE boundary", () => { + const inner = createInMemoryKeyring(); + const chunking = new ChunkingKeyringService(inner, 100); + const value = "x".repeat(100); + + chunking.setPassword(SERVICE, ACCOUNT, value); + + // Should be stored directly, not as a sentinel + expect(inner.getPassword(SERVICE, ACCOUNT)).toBe(value); + }); + + it("chunks value one character over MAX_CHUNK_SIZE", () => { + const inner = createInMemoryKeyring(); + const chunking = new ChunkingKeyringService(inner, 100); + const value = "x".repeat(101); + + chunking.setPassword(SERVICE, ACCOUNT, value); + + const sentinel = inner.getPassword(SERVICE, ACCOUNT); + expect(sentinel).toMatch(/^CHUNKED:/); + const parsed = parseChunkedSentinel(sentinel!); + expect(parsed!.count).toBe(2); + }); + + it("round-trips a chunked value through set and get", () => { + const inner = createInMemoryKeyring(); + const chunking = new ChunkingKeyringService(inner, 10); + const value = + '{"accessToken":"' + "a".repeat(50) + '","refreshToken":"b"}'; + + chunking.setPassword(SERVICE, ACCOUNT, value); + const result = chunking.getPassword(SERVICE, ACCOUNT); + + expect(result).toBe(value); + }); + }); +}); diff --git a/src/services/chunking-keyring-service.ts b/src/services/chunking-keyring-service.ts new file mode 100644 index 00000000..3436d796 --- /dev/null +++ b/src/services/chunking-keyring-service.ts @@ -0,0 +1,228 @@ +import type { KeyringService } from "./keyring-service.js"; + +/** + * Conservative chunk size for Windows Credential Manager. + * + * The Windows `CRED_MAX_CREDENTIAL_BLOB_SIZE` limit is 2560 **bytes**. + * The `@napi-rs/keyring` binding (via keyring-rs) encodes passwords as + * UTF-16 before writing, so each JS character costs 2 bytes. The true + * character limit is therefore `2560 / 2 = 1280`. + * + * We use 1200 to provide an 80-character safety margin for any encoding + * overhead in the binding layer between JS strings and the Windows DPAPI + * credential store. + * + * Pass a different value to the ChunkingKeyringService constructor if + * another platform has a different limit. + */ +export const WINDOWS_MAX_ENTRY_SIZE = 1200; + +/** + * Sentinel prefix stored in the main key when a value has been chunked. + * Format: `CHUNKED::` + * + * This prefix never collides with real stored data because all values + * written by KeychainAuthStorage are JSON-serialized objects starting + * with `{`. + */ +const CHUNKED_PREFIX = "CHUNKED:"; + +/** + * Maximum number of chunks allowed per value. Guards against accidentally + * storing pathologically large data (~240K chars at default chunk size). + */ +const MAX_CHUNK_COUNT = 100; + +/** + * Build the chunk key for a given account, write ID, and chunk index. + * Format: `{account}:chunk:{writeId}:{index}` + */ +export function chunkKey( + account: string, + writeId: string, + index: number, +): string { + return `${account}:chunk:${writeId}:${index}`; +} + +/** + * Parse a chunked sentinel value into its writeId and chunk count. + * Returns null if the value is not a valid sentinel (non-sentinel values, + * malformed counts, zero/negative counts). + */ +export function parseChunkedSentinel( + value: string, +): { writeId: string; count: number } | null { + if (!value.startsWith(CHUNKED_PREFIX)) return null; + const rest = value.slice(CHUNKED_PREFIX.length); + const colonIndex = rest.indexOf(":"); + if (colonIndex === -1) return null; + const writeId = rest.slice(0, colonIndex); + if (writeId.length === 0) return null; + const countStr = rest.slice(colonIndex + 1); + const count = Number(countStr); + if (!Number.isInteger(count) || count <= 0) return null; + return { writeId, count }; +} + +/** + * Split a string into chunks of at most `maxSize` characters. + * Always returns at least one element (empty string produces `[""]`). + */ +export function splitIntoChunks(value: string, maxSize: number): string[] { + if (value.length === 0) return [""]; + const chunks: string[] = []; + for (let offset = 0; offset < value.length; offset += maxSize) { + chunks.push(value.slice(offset, offset + maxSize)); + } + return chunks; +} + +/** + * Generate a short random write ID for namespacing chunk keys. + * Returns a 6-character alphanumeric string. Provides sufficient + * uniqueness for sequential writes by a single-user CLI. + */ +export function generateWriteId(): string { + let id: string; + do { + id = Math.random().toString(36).slice(2, 8); + } while (id.length < 6); + return id; +} + +/** + * KeyringService decorator that transparently chunks values exceeding a + * platform size limit (Windows Credential Manager's 2560-byte blob cap, + * which is 1280 characters after UTF-16 encoding). + * + * Values under the limit are stored directly (backward compatible with + * pre-chunking versions). Values over the limit are split into numbered + * chunks with a `CHUNKED::` sentinel in the main key. + * + * Each write uses a unique writeId to namespace chunk keys, ensuring + * crash safety: old sentinel+chunks remain valid until the new sentinel + * overwrites the main key. Old chunks are cleaned up after the new + * sentinel is committed. + * + * Intended for Windows only — wired conditionally in container.ts. + */ +export class ChunkingKeyringService implements KeyringService { + constructor( + private readonly inner: KeyringService, + private readonly maxEntrySize: number = WINDOWS_MAX_ENTRY_SIZE, + ) {} + + getPassword(service: string, account: string): string | null { + const value = this.inner.getPassword(service, account); + if (value === null) return null; + if (!value.startsWith(CHUNKED_PREFIX)) return value; + + const sentinel = parseChunkedSentinel(value); + if (sentinel === null) return null; + + const chunks: string[] = []; + for (let i = 0; i < sentinel.count; i++) { + const chunk = this.inner.getPassword( + service, + chunkKey(account, sentinel.writeId, i), + ); + if (chunk === null) { + console.error( + `Warning: Incomplete chunked keychain entry for "${account}" (missing chunk ${i} of ${sentinel.count}). Treating as missing.`, + ); + return null; + } + chunks.push(chunk); + } + return chunks.join(""); + } + + setPassword(service: string, account: string, password: string): void { + // Read old sentinel before writing so we can clean up old chunks afterward. + const oldValue = this.readOldSentinel(service, account); + + if (password.length <= this.maxEntrySize) { + // Value fits in a single entry — write directly. + this.inner.setPassword(service, account, password); + } else { + // Split into chunks with a new writeId namespace. + const chunks = splitIntoChunks(password, this.maxEntrySize); + if (chunks.length > MAX_CHUNK_COUNT) { + throw new Error( + `Value requires ${chunks.length} chunks, exceeding maximum of ${MAX_CHUNK_COUNT}. ` + + `This likely indicates a bug — credential data should not be this large.`, + ); + } + const writeId = generateWriteId(); + + // Write chunks first — if we crash here, the old sentinel still + // points to the old (valid) chunks. + for (const [i, chunk] of chunks.entries()) { + this.inner.setPassword(service, chunkKey(account, writeId, i), chunk); + } + + // Commit: write sentinel last so reads are atomic. + this.inner.setPassword( + service, + account, + `${CHUNKED_PREFIX}${writeId}:${chunks.length}`, + ); + } + + // Best-effort cleanup of old chunks (if any). + if (oldValue !== null) { + this.deleteChunkEntries(service, account, oldValue); + } + } + + deletePassword(service: string, account: string): boolean { + // Read sentinel before deleting so we know which chunks to clean up. + const oldValue = this.readOldSentinel(service, account); + if (oldValue !== null) { + this.deleteChunkEntries(service, account, oldValue); + } + return this.inner.deletePassword(service, account); + } + + /** + * Read the current main key value and parse it as a sentinel. + * Returns the parsed sentinel if the value is chunked, null otherwise. + * Swallows errors from the inner service since this is used for + * best-effort cleanup. + */ + private readOldSentinel( + service: string, + account: string, + ): { writeId: string; count: number } | null { + try { + const value = this.inner.getPassword(service, account); + if (value === null) return null; + return parseChunkedSentinel(value); + } catch { + return null; + } + } + + /** + * Delete chunk entries for a given sentinel. Best-effort: continues + * on individual failures since orphaned chunks are harmless and will + * be cleaned up on the next write or delete. + */ + private deleteChunkEntries( + service: string, + account: string, + sentinel: { writeId: string; count: number }, + ): void { + for (let i = 0; i < sentinel.count; i++) { + try { + this.inner.deletePassword( + service, + chunkKey(account, sentinel.writeId, i), + ); + } catch { + // Best-effort cleanup: orphaned chunks are harmless + } + } + } +} diff --git a/src/services/index.ts b/src/services/index.ts index 9991b006..1fc7c251 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -19,6 +19,10 @@ export type { export { AuthStorageImpl, normalizeBaseUrl } from "./auth-storage.js"; export type { BrowserService } from "./browser-service.js"; export { BrowserServiceImpl } from "./browser-service.js"; +export { + ChunkingKeyringService, + WINDOWS_MAX_ENTRY_SIZE, +} from "./chunking-keyring-service.js"; export { getApiUrl, getEnvApiToken, getMcpUrl } from "./config.js"; export type { FileSystemService } from "./filesystem-service.js"; export { FileSystemServiceImpl } from "./filesystem-service.js";