diff --git a/packages/chrome-extension/src/content-scripts/capture-patterns.ts b/packages/chrome-extension/src/content-scripts/capture-patterns.ts index be4c50a..5a96755 100644 --- a/packages/chrome-extension/src/content-scripts/capture-patterns.ts +++ b/packages/chrome-extension/src/content-scripts/capture-patterns.ts @@ -486,6 +486,16 @@ export function matchAgainstCapturePatterns( /** * Get platform-specific selectors for the current hostname. */ +/** + * Get the regex source string for a capture pattern by ID. + * Returns the structural pattern (e.g. "sk-proj-[A-Za-z0-9_-]{20,}") — never a literal key value. + * Returns null if patternId is not found (e.g. aws-secret-key which has no regex in CAPTURE_PATTERNS). + */ +export function getPatternRegexSource(patternId: string): string | null { + const pattern = CAPTURE_PATTERNS.find(p => p.id === patternId); + return pattern ? pattern.regex.source : null; +} + export function getPlatformSelectors(hostname: string): Array<{ pattern: CapturePattern; selector: PlatformSelector; diff --git a/packages/chrome-extension/src/content-scripts/masker.ts b/packages/chrome-extension/src/content-scripts/masker.ts index 2a08bfa..9059b29 100644 --- a/packages/chrome-extension/src/content-scripts/masker.ts +++ b/packages/chrome-extension/src/content-scripts/masker.ts @@ -15,6 +15,7 @@ import { getPlatformSelectors, getWatchSelectors, getPreHideCSS, + getPatternRegexSource, DOMAIN_SERVICE_MAP, type CaptureMatch, } from './capture-patterns'; @@ -748,6 +749,65 @@ function isAwsConsolePage(hostname: string): boolean { hostname.endsWith('.console.aws.amazon.com'); } +/** + * Auto-click the Secret Access Key copy button on AWS key creation result page. + * The container has 2 copy buttons: [0] = Access Key ID, [1] = Secret Access Key. + * Clicking triggers clipboard.writeText → clipboard-patch.ts → handleClipboardText. + */ +function autoClickAwsSecretKeyCopy() { + setTimeout(() => { + const container = document.querySelector('.create-root-access-key-container'); + if (!container) return; + const copyButtons = container.querySelectorAll('button[data-testid="copy-button"]'); + if (copyButtons.length >= 2) { + copyButtons[1].click(); + } + }, 500); +} + +/** + * AI Studio: auto-click the copy button for the most recently created key. + * After key creation, AI Studio closes the dialog and returns to the list. + * The list only shows truncated keys — full key is only available via the copy button + * which uses navigator.clipboard.writeText (intercepted by clipboard-patch.ts). + */ +let aiStudioKeyCount = -1; + +function isAiStudioKeyPage(): boolean { + return window.location.hostname === 'aistudio.google.com' && + window.location.pathname.startsWith('/api-keys'); +} + +function initAiStudioWatcher() { + if (!isAiStudioKeyPage()) return; + // Snapshot baseline after page fully settles + setTimeout(() => { + const btns = document.querySelectorAll('button.xap-copy-to-clipboard'); + aiStudioKeyCount = btns.length; + }, 5000); +} + +function watchAiStudioKeyCreation() { + if (!isAiStudioKeyPage()) return; + if (!shouldAutoCapture()) return; + if (aiStudioKeyCount === -1) return; // Not initialized yet + + const currentCount = document.querySelectorAll('button.xap-copy-to-clipboard').length; + + // Ignore transient count=0 during Angular re-render (delete/navigation) + if (currentCount === 0) return; + + const prevCount = aiStudioKeyCount; + aiStudioKeyCount = currentCount; + + if (currentCount > prevCount) { + setTimeout(() => { + const btn = document.querySelector('button.xap-copy-to-clipboard'); + if (btn) btn.click(); + }, 500); + } +} + // Listen for clipboard writeText events from clipboard-patch.ts (MAIN world) let clipboardListenerActive = false; @@ -775,19 +835,10 @@ function generateMaskedPreview(rawValue: string): string { return rawValue.length > 12 ? rawValue.slice(0, 8) + '****...' : '****...****'; } -function isAlreadyStoredKey(value: string): boolean { - for (const [, regex] of compiledPatterns) { - regex.lastIndex = 0; - if (regex.test(value)) return true; - } - return false; -} - function submitCapturedKey(match: CaptureMatch) { const trimmedValue = match.rawValue.trim(); if (submittedKeys.has(trimmedValue)) return; if (rejectedKeys.has(trimmedValue)) return; - if (isAlreadyStoredKey(trimmedValue)) return; submittedKeys.add(trimmedValue); match.rawValue = trimmedValue; @@ -803,6 +854,7 @@ function submitCapturedKey(match: CaptureMatch) { sourceURL: window.location.href, confidence: match.confidence, captureMethod: match.captureMethod, + pattern: getPatternRegexSource(match.patternId), }, }).catch(() => {}); if (isDemoMode) { @@ -810,6 +862,11 @@ function submitCapturedKey(match: CaptureMatch) { } showToast(match.serviceName, preview); + // AWS dual-key: after Access Key ID is captured, auto-click Secret Key copy button + if (match.patternId === 'aws-access-key') { + autoClickAwsSecretKeyCopy(); + } + } else if (match.confidence >= CONFIDENCE_MIN) { // Medium confidence — mask first (prevent leak), then show confirmation if (isDemoMode) { @@ -874,6 +931,7 @@ function showConfirmationDialog(match: CaptureMatch, preview: string) { suggestedService: serviceInput.value.trim() || match.serviceName, sourceURL: window.location.href, captureMethod: match.captureMethod, + pattern: getPatternRegexSource(match.patternId), }, }).catch(() => {}); closeDialog(false); @@ -1124,6 +1182,7 @@ function startObserver() { debouncedScan(); if (shouldAutoCapture()) { debouncedCaptureScan(); + watchAiStudioKeyCreation(); } } }); @@ -1138,6 +1197,7 @@ function startObserver() { // MARK: - Bootstrap startObserver(); +initAiStudioWatcher(); // Request current state from background on load chrome.runtime.sendMessage({ type: 'get_state' }, (response) => { diff --git a/packages/swift-core/DemoSafe/App/AppState.swift b/packages/swift-core/DemoSafe/App/AppState.swift index 2dd2c55..163ce99 100644 --- a/packages/swift-core/DemoSafe/App/AppState.swift +++ b/packages/swift-core/DemoSafe/App/AppState.swift @@ -50,10 +50,7 @@ final class AppState: ObservableObject { seedDefaultContextModes() } - // Seed test keys for development (keys added by DemoSafe itself → correct Keychain ACL) - #if DEBUG - seedTestKeysIfNeeded() - #endif + // Test key seeding removed — Active Key Capture handles key storage via Chrome Extension. // Wire settings window controller SettingsWindowController.shared.setAppState(self) @@ -218,54 +215,4 @@ final class AppState: ObservableObject { maskingCoordinator.activeContext = activeContext } - /// Seed test keys for development. Keys are added by DemoSafe itself - /// so Keychain ACL automatically matches the current binary. - private func seedTestKeysIfNeeded() { - let testKeys: [(label: String, serviceName: String, pattern: String, value: String)] = [ - ("test-key-1", "OpenAI", "sk-proj-[a-zA-Z0-9_-]+", "sk-proj-TestKey1234567890abcdef"), - ("openai-dev", "OpenAI", "sk-proj-[a-zA-Z0-9_-]+", "sk-devTestKey9876543210"), - ("anthropic-prod", "Anthropic", "sk-ant-[a-zA-Z0-9_-]+", "sk-ant-test1234567890abcdef"), - ("aws-access-key", "AWS", "AKIA[0-9A-Z]{16}", "AKIAIOSFODNN7EXAMPLE1"), - ("stripe-live", "Stripe", "sk_live_[a-zA-Z0-9]+", "sk_live_test1234567890abcdef"), - ] - - let existingKeys = vaultManager.getAllKeys() - - for testKey in testKeys { - // If key exists in vault, always refresh Keychain (ACL changes on rebuild) - if let existing = existingKeys.first(where: { $0.label == testKey.label }) { - try? keychainService.deleteKey(keyId: existing.id) - if let data = testKey.value.data(using: .utf8) { - try? keychainService.storeKey(keyId: existing.id, value: data) - } - continue - } - - // Find or create service - var service = vaultManager.getAllServices().first(where: { $0.name == testKey.serviceName }) - if service == nil { - let newService = Service( - id: UUID(), name: testKey.serviceName, icon: nil, - defaultPattern: testKey.pattern, defaultMaskFormat: .default, isBuiltIn: false - ) - try? vaultManager.addService(newService) - service = newService - } - - guard let svc = service, let valueData = testKey.value.data(using: .utf8) else { continue } - - do { - _ = try vaultManager.addKey( - label: testKey.label, - serviceId: svc.id, - pattern: testKey.pattern, - maskFormat: .default, - value: valueData - ) - logger.info("Seeded test key: \(testKey.label)") - } catch { - logger.error("Failed to seed \(testKey.label): \(error)") - } - } - } } diff --git a/packages/swift-core/DemoSafe/Models/KeyEntry.swift b/packages/swift-core/DemoSafe/Models/KeyEntry.swift index 1e9f3e1..8cbd071 100644 --- a/packages/swift-core/DemoSafe/Models/KeyEntry.swift +++ b/packages/swift-core/DemoSafe/Models/KeyEntry.swift @@ -1,4 +1,5 @@ import Foundation +import CryptoKit /// A single API key entry stored in the vault. struct KeyEntry: Codable, Identifiable { @@ -12,4 +13,11 @@ struct KeyEntry: Codable, Identifiable { var updatedAt: Date var linkedGroupId: UUID? var sortOrder: Int? + var valueHash: String? // SHA-256 hash for dedup without Keychain access + + /// Compute SHA-256 hash of key value data. + static func computeHash(_ data: Data) -> String { + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } } diff --git a/packages/swift-core/DemoSafe/Services/IPC/IPCServer.swift b/packages/swift-core/DemoSafe/Services/IPC/IPCServer.swift index 251d696..6f99967 100644 --- a/packages/swift-core/DemoSafe/Services/IPC/IPCServer.swift +++ b/packages/swift-core/DemoSafe/Services/IPC/IPCServer.swift @@ -426,18 +426,37 @@ final class IPCServer { return } + // Deduplicate: check if this exact key value already exists in Keychain + if let existing = self.vaultManager.isDuplicateKey(serviceId: svc.id, value: valueData) { + ipcLogger.warning("submit_captured_key: duplicate key, skipping store") + let response: [String: Any] = [ + "id": messageId, "type": "response", "action": "submit_captured_key", + "payload": ["status": "duplicate", "label": existing.label, "serviceName": suggestedService], + "timestamp": ISO8601DateFormatter().string(from: Date()), + ] + self.sendJSON(response, clientId: clientId) + return + } + // Generate a simple label from service + timestamp let label = "\(suggestedService.lowercased())-\(Int(Date().timeIntervalSince1970) % 100000)" - // Generate a regex pattern that matches this specific key - let escapedValue = NSRegularExpression.escapedPattern(for: rawValue) + // Use structural pattern from Extension (e.g. "sk-proj-[A-Za-z0-9_-]{20,}") + // Never store the literal key value as pattern — that would leak plaintext via IPC broadcast + let structuralPattern: String + if let extensionPattern = payload["pattern"] as? String, !extensionPattern.isEmpty { + structuralPattern = extensionPattern + } else { + // Fallback: derive pattern from key structure (prefix + char class + length) + structuralPattern = Self.deriveStructuralPattern(from: rawValue) + } do { - ipcLogger.warning("storing key: label=\(label) patternLen=\(escapedValue.count)") + ipcLogger.warning("storing key: label=\(label) patternLen=\(structuralPattern.count)") _ = try self.vaultManager.addKey( label: label, serviceId: svc.id, - pattern: escapedValue, + pattern: structuralPattern, maskFormat: svc.defaultMaskFormat, value: valueData ) @@ -585,6 +604,67 @@ final class IPCServer { return try? JSONSerialization.data(withJSONObject: json) } + // MARK: - Private — Structural Pattern Derivation + + /// Derive a structural regex pattern from a raw key value. + /// Preserves known prefix, replaces remainder with character class + exact length. + /// e.g. "sk-proj-abc123XYZ" → "sk\\-proj\\-[A-Za-z0-9]{10}" + /// This is the fallback when the Extension does not provide a pattern. + static func deriveStructuralPattern(from rawValue: String) -> String { + let knownPrefixes = [ + "sk-proj-", "sk-ant-api03-", "sk-ant-", "sk-or-v1-", + "sk-", "pk-", + "ghp_", "gho_", "ghu_", "ghs_", "ghr_", + "AKIA", "ASIA", + "glpat-", "glsa-", + "xoxb-", "xoxp-", "xoxe-", + "key-", "token-", "api-", "secret-", "rk-", + "AIza", + ] + + var prefix = "" + var remainder = rawValue + // Match longest prefix first + for p in knownPrefixes.sorted(by: { $0.count > $1.count }) { + if rawValue.hasPrefix(p) { + prefix = p + remainder = String(rawValue.dropFirst(p.count)) + break + } + } + + let escapedPrefix = NSRegularExpression.escapedPattern(for: prefix) + let charClass = Self.inferCharacterClass(remainder) + let len = remainder.count + + return "\(escapedPrefix)\(charClass){\(len)}" + } + + private static func inferCharacterClass(_ s: String) -> String { + let hasUpper = s.range(of: "[A-Z]", options: .regularExpression) != nil + let hasLower = s.range(of: "[a-z]", options: .regularExpression) != nil + let hasDigit = s.range(of: "[0-9]", options: .regularExpression) != nil + let hasUnderscore = s.contains("_") + let hasDash = s.contains("-") + let hasSlash = s.contains("/") + let hasPlus = s.contains("+") + let hasEquals = s.contains("=") + + var cls = "" + if hasUpper { cls += "A-Z" } + if hasLower { cls += "a-z" } + if hasDigit { cls += "0-9" } + if hasUnderscore { cls += "_" } + if hasDash { cls += "\\-" } + if hasSlash { cls += "/" } + if hasPlus { cls += "\\+" } + if hasEquals { cls += "=" } + + if cls.isEmpty { cls = "A-Za-z0-9" } + + return "[\(cls)]" + } + // MARK: - Private — Token & ipc.json /// Generate a 32-byte cryptographically secure random token (hex encoded = 64 chars). diff --git a/packages/swift-core/DemoSafe/Services/VaultManager.swift b/packages/swift-core/DemoSafe/Services/VaultManager.swift index cf8ef94..6a00b22 100644 --- a/packages/swift-core/DemoSafe/Services/VaultManager.swift +++ b/packages/swift-core/DemoSafe/Services/VaultManager.swift @@ -94,7 +94,8 @@ final class VaultManager { createdAt: now, updatedAt: now, linkedGroupId: nil, - sortOrder: vault.keys.filter({ $0.serviceId == serviceId }).count + sortOrder: vault.keys.filter({ $0.serviceId == serviceId }).count, + valueHash: KeyEntry.computeHash(value) ) // Store in Keychain first @@ -155,6 +156,12 @@ final class VaultManager { return vault.keys } + /// Check if a key value already exists by comparing SHA-256 hashes (no Keychain access). + func isDuplicateKey(serviceId: UUID, value: Data) -> KeyEntry? { + let hash = KeyEntry.computeHash(value) + return vault.keys.first { $0.serviceId == serviceId && $0.valueHash == hash } + } + // MARK: - LinkedGroup CRUD func createLinkedGroup(label: String, keyIds: [UUID], pasteMode: PasteMode) throws -> LinkedGroup {