Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/chrome-extension/src/content-scripts/capture-patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
78 changes: 69 additions & 9 deletions packages/chrome-extension/src/content-scripts/masker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getPlatformSelectors,
getWatchSelectors,
getPreHideCSS,
getPatternRegexSource,
DOMAIN_SERVICE_MAP,
type CaptureMatch,
} from './capture-patterns';
Expand Down Expand Up @@ -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<HTMLButtonElement>('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<HTMLButtonElement>('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<HTMLButtonElement>('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<HTMLButtonElement>('button.xap-copy-to-clipboard');
if (btn) btn.click();
}, 500);
}
}

// Listen for clipboard writeText events from clipboard-patch.ts (MAIN world)
let clipboardListenerActive = false;

Expand Down Expand Up @@ -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;

Expand All @@ -803,13 +854,19 @@ function submitCapturedKey(match: CaptureMatch) {
sourceURL: window.location.href,
confidence: match.confidence,
captureMethod: match.captureMethod,
pattern: getPatternRegexSource(match.patternId),
},
}).catch(() => {});
if (isDemoMode) {
immediatelyMaskValue(trimmedValue, match.serviceName, preview);
}
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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1124,6 +1182,7 @@ function startObserver() {
debouncedScan();
if (shouldAutoCapture()) {
debouncedCaptureScan();
watchAiStudioKeyCreation();
}
}
});
Expand All @@ -1138,6 +1197,7 @@ function startObserver() {
// MARK: - Bootstrap

startObserver();
initAiStudioWatcher();

// Request current state from background on load
chrome.runtime.sendMessage({ type: 'get_state' }, (response) => {
Expand Down
55 changes: 1 addition & 54 deletions packages/swift-core/DemoSafe/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)")
}
}
}
}
8 changes: 8 additions & 0 deletions packages/swift-core/DemoSafe/Models/KeyEntry.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import CryptoKit

/// A single API key entry stored in the vault.
struct KeyEntry: Codable, Identifiable {
Expand All @@ -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()
}
}
88 changes: 84 additions & 4 deletions packages/swift-core/DemoSafe/Services/IPC/IPCServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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).
Expand Down
9 changes: 8 additions & 1 deletion packages/swift-core/DemoSafe/Services/VaultManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading