agent-vault-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/.cursor/skills/npm-publish/SKILL.md +58 -0
  2. package/.github/workflows/ci.yml +67 -0
  3. package/README.md +164 -0
  4. package/ROADMAP.md +986 -0
  5. package/dist/commands/config.d.ts +8 -0
  6. package/dist/commands/config.d.ts.map +1 -0
  7. package/dist/commands/config.js +67 -0
  8. package/dist/commands/config.js.map +1 -0
  9. package/dist/commands/delete.d.ts +7 -0
  10. package/dist/commands/delete.d.ts.map +1 -0
  11. package/dist/commands/delete.js +30 -0
  12. package/dist/commands/delete.js.map +1 -0
  13. package/dist/commands/login.d.ts +7 -0
  14. package/dist/commands/login.d.ts.map +1 -0
  15. package/dist/commands/login.js +37 -0
  16. package/dist/commands/login.js.map +1 -0
  17. package/dist/commands/register.d.ts +13 -0
  18. package/dist/commands/register.d.ts.map +1 -0
  19. package/dist/commands/register.js +160 -0
  20. package/dist/commands/register.js.map +1 -0
  21. package/dist/core/audit.d.ts +15 -0
  22. package/dist/core/audit.d.ts.map +1 -0
  23. package/dist/core/audit.js +36 -0
  24. package/dist/core/audit.js.map +1 -0
  25. package/dist/core/browser.d.ts +7 -0
  26. package/dist/core/browser.d.ts.map +1 -0
  27. package/dist/core/browser.js +104 -0
  28. package/dist/core/browser.js.map +1 -0
  29. package/dist/core/config.d.ts +9 -0
  30. package/dist/core/config.d.ts.map +1 -0
  31. package/dist/core/config.js +80 -0
  32. package/dist/core/config.js.map +1 -0
  33. package/dist/core/crypto.d.ts +17 -0
  34. package/dist/core/crypto.d.ts.map +1 -0
  35. package/dist/core/crypto.js +90 -0
  36. package/dist/core/crypto.js.map +1 -0
  37. package/dist/core/fields.d.ts +5 -0
  38. package/dist/core/fields.d.ts.map +1 -0
  39. package/dist/core/fields.js +54 -0
  40. package/dist/core/fields.js.map +1 -0
  41. package/dist/core/keychain.d.ts +5 -0
  42. package/dist/core/keychain.d.ts.map +1 -0
  43. package/dist/core/keychain.js +97 -0
  44. package/dist/core/keychain.js.map +1 -0
  45. package/dist/core/origin.d.ts +25 -0
  46. package/dist/core/origin.d.ts.map +1 -0
  47. package/dist/core/origin.js +73 -0
  48. package/dist/core/origin.js.map +1 -0
  49. package/dist/core/ratelimit.d.ts +10 -0
  50. package/dist/core/ratelimit.d.ts.map +1 -0
  51. package/dist/core/ratelimit.js +70 -0
  52. package/dist/core/ratelimit.js.map +1 -0
  53. package/dist/core/secure-memory.d.ts +39 -0
  54. package/dist/core/secure-memory.d.ts.map +1 -0
  55. package/dist/core/secure-memory.js +68 -0
  56. package/dist/core/secure-memory.js.map +1 -0
  57. package/dist/index.d.ts +3 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +129 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/types/index.d.ts +27 -0
  62. package/dist/types/index.d.ts.map +1 -0
  63. package/dist/types/index.js +2 -0
  64. package/dist/types/index.js.map +1 -0
  65. package/package.json +58 -0
  66. package/src/commands/config.ts +84 -0
  67. package/src/commands/delete.ts +39 -0
  68. package/src/commands/login.ts +49 -0
  69. package/src/commands/register.ts +188 -0
  70. package/src/core/audit.ts +59 -0
  71. package/src/core/browser.ts +131 -0
  72. package/src/core/config.ts +91 -0
  73. package/src/core/crypto.ts +106 -0
  74. package/src/core/fields.ts +59 -0
  75. package/src/core/keychain.ts +110 -0
  76. package/src/core/origin.ts +90 -0
  77. package/src/core/ratelimit.ts +89 -0
  78. package/src/core/secure-memory.ts +78 -0
  79. package/src/index.ts +133 -0
  80. package/src/types/index.ts +31 -0
  81. package/tests/browser-password-manager.test.ts +1023 -0
  82. package/tests/crypto.test.ts +140 -0
  83. package/tests/e2e.test.ts +565 -0
  84. package/tests/fixtures/server.ts +59 -0
  85. package/tests/security.test.ts +113 -0
  86. package/tsconfig.json +20 -0
  87. package/vitest.config.ts +17 -0
@@ -0,0 +1,59 @@
1
+ import { appendFile, mkdir } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ const AUDIT_DIR = join(homedir(), '.agent-vault');
6
+ const AUDIT_FILE = join(AUDIT_DIR, 'audit.log');
7
+
8
+ export type AuditEvent =
9
+ | 'credential_stored'
10
+ | 'credential_retrieved'
11
+ | 'credential_deleted'
12
+ | 'login_filled'
13
+ | 'config_changed'
14
+ | 'rate_limit_exceeded';
15
+
16
+ interface AuditEntry {
17
+ timestamp: string;
18
+ event: AuditEvent;
19
+ origin?: string;
20
+ details?: string;
21
+ success: boolean;
22
+ }
23
+
24
+ async function ensureAuditDir(): Promise<void> {
25
+ await mkdir(AUDIT_DIR, { recursive: true, mode: 0o700 });
26
+ }
27
+
28
+ /**
29
+ * Log an audit event to the audit log file.
30
+ * Never includes sensitive data like passwords or usernames.
31
+ */
32
+ export async function logAuditEvent(
33
+ event: AuditEvent,
34
+ options: { origin?: string; details?: string; success?: boolean } = {}
35
+ ): Promise<void> {
36
+ try {
37
+ await ensureAuditDir();
38
+
39
+ const entry: AuditEntry = {
40
+ timestamp: new Date().toISOString(),
41
+ event,
42
+ origin: options.origin,
43
+ details: options.details,
44
+ success: options.success ?? true,
45
+ };
46
+
47
+ const line = JSON.stringify(entry) + '\n';
48
+ await appendFile(AUDIT_FILE, line, { mode: 0o600 });
49
+ } catch {
50
+ // Silently fail - audit logging should never break the main flow
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Get the audit log file path (for admin/debug purposes)
56
+ */
57
+ export function getAuditLogPath(): string {
58
+ return AUDIT_FILE;
59
+ }
@@ -0,0 +1,131 @@
1
+ import { chromium } from 'playwright';
2
+ import type { BrowserConnection } from '../types/index.js';
3
+ import { getConfigValue } from './config.js';
4
+
5
+ const CDP_TIMEOUT_MS = 10000;
6
+
7
+ // Default allowed CDP hosts for security
8
+ const DEFAULT_CDP_ALLOWLIST = ['127.0.0.1', 'localhost', '::1'];
9
+
10
+ /**
11
+ * Parse CDP allowlist from config
12
+ */
13
+ async function getCdpAllowlist(): Promise<string[]> {
14
+ const configValue = await getConfigValue('cdpAllowlist');
15
+ if (configValue) {
16
+ return configValue.split(',').map((h) => h.trim().toLowerCase());
17
+ }
18
+ return DEFAULT_CDP_ALLOWLIST;
19
+ }
20
+
21
+ /**
22
+ * Check if a CDP endpoint host is in the allowlist
23
+ */
24
+ async function isAllowedCdpHost(hostname: string): Promise<boolean> {
25
+ const allowlist = await getCdpAllowlist();
26
+ return allowlist.includes(hostname.toLowerCase());
27
+ }
28
+
29
+ export async function validateCDPEndpoint(endpoint: string): Promise<void> {
30
+ let url: URL;
31
+ try {
32
+ url = new URL(endpoint);
33
+ } catch {
34
+ throw new Error('Invalid CDP endpoint format');
35
+ }
36
+
37
+ if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
38
+ throw new Error('CDP endpoint must use WebSocket protocol');
39
+ }
40
+
41
+ if (!url.hostname) {
42
+ throw new Error('CDP endpoint must have a valid hostname');
43
+ }
44
+
45
+ // Check against allowlist
46
+ const isAllowed = await isAllowedCdpHost(url.hostname);
47
+ if (!isAllowed) {
48
+ throw new Error(
49
+ 'CDP endpoint host is not in the allowlist. ' +
50
+ 'Add it with: vault config set cdpAllowlist "host1,host2"'
51
+ );
52
+ }
53
+ }
54
+
55
+ export async function connectToBrowser(cdpEndpoint: string): Promise<BrowserConnection> {
56
+ await validateCDPEndpoint(cdpEndpoint);
57
+
58
+ let browser;
59
+ try {
60
+ browser = await Promise.race([
61
+ chromium.connectOverCDP(cdpEndpoint),
62
+ new Promise<never>((_, reject) =>
63
+ setTimeout(() => reject(new Error('CDP connection timeout')), CDP_TIMEOUT_MS)
64
+ ),
65
+ ]);
66
+ } catch (error) {
67
+ // Sanitize connection errors - don't expose internal details
68
+ if (error instanceof Error) {
69
+ if (error.message.includes('timeout')) {
70
+ throw new Error('CDP connection timeout - ensure browser is running with remote debugging');
71
+ }
72
+ if (error.message.includes('ECONNREFUSED')) {
73
+ throw new Error('Cannot connect to CDP endpoint - ensure browser is running');
74
+ }
75
+ }
76
+ throw new Error('Failed to connect to browser');
77
+ }
78
+
79
+ const contexts = browser.contexts();
80
+
81
+ if (contexts.length === 0) {
82
+ await browser.close();
83
+ throw new Error('No browser context available');
84
+ }
85
+
86
+ const pages = contexts[0].pages();
87
+
88
+ if (pages.length === 0) {
89
+ await browser.close();
90
+ throw new Error('No browser page available');
91
+ }
92
+
93
+ return { browser, page: pages[0] };
94
+ }
95
+
96
+ export async function fillField(
97
+ page: BrowserConnection['page'],
98
+ selector: string,
99
+ value: string
100
+ ): Promise<void> {
101
+ try {
102
+ await page.fill(selector, value);
103
+ } catch {
104
+ // Sanitize selector errors - don't expose selector in error message
105
+ throw new Error('Failed to fill form field - selector may be invalid');
106
+ }
107
+ }
108
+
109
+ export async function clickElement(
110
+ page: BrowserConnection['page'],
111
+ selector: string
112
+ ): Promise<void> {
113
+ try {
114
+ await page.click(selector);
115
+ } catch {
116
+ // Sanitize selector errors
117
+ throw new Error('Failed to click element - selector may be invalid');
118
+ }
119
+ }
120
+
121
+ export async function validateSelector(
122
+ page: BrowserConnection['page'],
123
+ selector: string
124
+ ): Promise<boolean> {
125
+ try {
126
+ const element = await page.$(selector);
127
+ return element !== null;
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
@@ -0,0 +1,91 @@
1
+ import { readFile, writeFile, mkdir, chmod, access, constants } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { lock, unlock } from 'proper-lockfile';
5
+ import type { VaultConfig, ConfigKey } from '../types/index.js';
6
+ import { logAuditEvent } from './audit.js';
7
+
8
+ const CONFIG_DIR = join(homedir(), '.agent-vault');
9
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
10
+
11
+ // Secure file permissions: owner read/write only
12
+ const SECURE_DIR_MODE = 0o700;
13
+ const SECURE_FILE_MODE = 0o600;
14
+
15
+ async function ensureConfigDir(): Promise<void> {
16
+ await mkdir(CONFIG_DIR, { recursive: true, mode: SECURE_DIR_MODE });
17
+ }
18
+
19
+ async function fileExists(path: string): Promise<boolean> {
20
+ try {
21
+ await access(path, constants.F_OK);
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ export async function loadConfig(): Promise<VaultConfig> {
29
+ try {
30
+ const data = await readFile(CONFIG_FILE, 'utf-8');
31
+ return JSON.parse(data);
32
+ } catch {
33
+ return {};
34
+ }
35
+ }
36
+
37
+ export async function saveConfig(config: VaultConfig): Promise<void> {
38
+ await ensureConfigDir();
39
+
40
+ const exists = await fileExists(CONFIG_FILE);
41
+
42
+ // Create empty file if it doesn't exist (for locking)
43
+ if (!exists) {
44
+ await writeFile(CONFIG_FILE, '{}', { mode: SECURE_FILE_MODE });
45
+ }
46
+
47
+ // Use file locking to prevent race conditions
48
+ let release: (() => Promise<void>) | null = null;
49
+ try {
50
+ release = await lock(CONFIG_FILE, { retries: 3 });
51
+ await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: SECURE_FILE_MODE });
52
+ // Ensure permissions are set correctly
53
+ await chmod(CONFIG_FILE, SECURE_FILE_MODE);
54
+ } finally {
55
+ if (release) {
56
+ await release();
57
+ }
58
+ }
59
+ }
60
+
61
+ export async function getConfigValue(key: ConfigKey): Promise<string | undefined> {
62
+ const config = await loadConfig();
63
+ return config[key];
64
+ }
65
+
66
+ export async function setConfigValue(key: ConfigKey, value: string): Promise<void> {
67
+ const config = await loadConfig();
68
+ config[key] = value;
69
+ await saveConfig(config);
70
+ await logAuditEvent('config_changed', { details: `Key: ${key}` });
71
+ }
72
+
73
+ export async function unsetConfigValue(key: ConfigKey): Promise<boolean> {
74
+ const config = await loadConfig();
75
+ if (key in config) {
76
+ delete config[key];
77
+ await saveConfig(config);
78
+ await logAuditEvent('config_changed', { details: `Key removed: ${key}` });
79
+ return true;
80
+ }
81
+ return false;
82
+ }
83
+
84
+ export function isValidConfigKey(key: string): key is ConfigKey {
85
+ const validKeys: ConfigKey[] = ['defaultUsername', 'allowHttp', 'cdpAllowlist'];
86
+ return validKeys.includes(key as ConfigKey);
87
+ }
88
+
89
+ export function getValidConfigKeys(): ConfigKey[] {
90
+ return ['defaultUsername', 'allowHttp', 'cdpAllowlist'];
91
+ }
@@ -0,0 +1,106 @@
1
+ import { randomInt } from 'node:crypto';
2
+ import { customAlphabet } from 'nanoid';
3
+
4
+ const PASSWORD_LENGTH = 24;
5
+ const MIN_PASSWORD_LENGTH = 12;
6
+ const PASSWORD_CHARSET =
7
+ 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
8
+
9
+ const CHAR_CLASSES = {
10
+ lowercase: 'abcdefghijklmnopqrstuvwxyz',
11
+ uppercase: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
12
+ digits: '0123456789',
13
+ special: '!@#$%^&*',
14
+ } as const;
15
+
16
+ /**
17
+ * Cryptographically secure random character selection
18
+ */
19
+ function secureRandomChar(charset: string): string {
20
+ return charset[randomInt(charset.length)];
21
+ }
22
+
23
+ /**
24
+ * Cryptographically secure Fisher-Yates shuffle
25
+ */
26
+ function secureShuffleArray<T>(array: T[]): T[] {
27
+ const result = [...array];
28
+ for (let i = result.length - 1; i > 0; i--) {
29
+ const j = randomInt(i + 1);
30
+ [result[i], result[j]] = [result[j], result[i]];
31
+ }
32
+ return result;
33
+ }
34
+
35
+ /**
36
+ * Check if password meets complexity requirements
37
+ */
38
+ function meetsComplexityRequirements(password: string): boolean {
39
+ return (
40
+ /[a-z]/.test(password) &&
41
+ /[A-Z]/.test(password) &&
42
+ /[0-9]/.test(password) &&
43
+ /[!@#$%^&*]/.test(password)
44
+ );
45
+ }
46
+
47
+ /**
48
+ * Generate a cryptographically secure password using Node.js crypto.randomInt
49
+ * Guarantees at least one character from each class (lowercase, uppercase, digit, special)
50
+ *
51
+ * @param length - Password length (minimum 12, default 24)
52
+ * @throws Error if length is less than minimum
53
+ */
54
+ export function generatePassword(length: number = PASSWORD_LENGTH): string {
55
+ if (length < MIN_PASSWORD_LENGTH) {
56
+ throw new Error(`Password length must be at least ${MIN_PASSWORD_LENGTH} characters`);
57
+ }
58
+
59
+ // Start with one guaranteed character from each class
60
+ const chars = [
61
+ secureRandomChar(CHAR_CLASSES.lowercase),
62
+ secureRandomChar(CHAR_CLASSES.uppercase),
63
+ secureRandomChar(CHAR_CLASSES.digits),
64
+ secureRandomChar(CHAR_CLASSES.special),
65
+ ];
66
+
67
+ // Fill remaining length with random characters from full charset
68
+ while (chars.length < length) {
69
+ chars.push(secureRandomChar(PASSWORD_CHARSET));
70
+ }
71
+
72
+ // Cryptographically secure shuffle to randomize position of guaranteed chars
73
+ return secureShuffleArray(chars).join('');
74
+ }
75
+
76
+ /**
77
+ * Alternative password generator using nanoid's customAlphabet
78
+ * Regenerates until complexity requirements are met
79
+ *
80
+ * @param length - Password length (minimum 12, default 24)
81
+ * @throws Error if length is less than minimum
82
+ */
83
+ export function generatePasswordNanoid(length: number = PASSWORD_LENGTH): string {
84
+ if (length < MIN_PASSWORD_LENGTH) {
85
+ throw new Error(`Password length must be at least ${MIN_PASSWORD_LENGTH} characters`);
86
+ }
87
+
88
+ const generate = customAlphabet(PASSWORD_CHARSET, length);
89
+
90
+ // Generate until we meet complexity requirements
91
+ // With 24 chars from a 70-char alphabet including all classes,
92
+ // probability of missing a class is extremely low (~0.01%)
93
+ let password: string;
94
+ let attempts = 0;
95
+ const maxAttempts = 100;
96
+
97
+ do {
98
+ password = generate();
99
+ attempts++;
100
+ if (attempts >= maxAttempts) {
101
+ throw new Error('Failed to generate compliant password after maximum attempts');
102
+ }
103
+ } while (!meetsComplexityRequirements(password));
104
+
105
+ return password;
106
+ }
@@ -0,0 +1,59 @@
1
+ import type { Page } from 'playwright';
2
+
3
+ const USERNAME_SELECTORS = [
4
+ 'input[type="email"]',
5
+ 'input[name="email"]',
6
+ 'input[autocomplete="username"]',
7
+ 'input[autocomplete="email"]',
8
+ 'input[name="username"]',
9
+ 'input[id="username"]',
10
+ 'input[id="email"]',
11
+ ];
12
+
13
+ const PASSWORD_SELECTORS = [
14
+ 'input[type="password"]',
15
+ 'input[autocomplete="current-password"]',
16
+ 'input[autocomplete="new-password"]',
17
+ ];
18
+
19
+ const SUBMIT_SELECTORS = [
20
+ 'button[type="submit"]',
21
+ 'input[type="submit"]',
22
+ 'button:has-text("Log in")',
23
+ 'button:has-text("Sign in")',
24
+ 'button:has-text("Login")',
25
+ ];
26
+
27
+ export async function detectUsernameField(page: Page): Promise<string | null> {
28
+ for (const selector of USERNAME_SELECTORS) {
29
+ const element = await page.$(selector);
30
+ if (element && await element.isVisible()) {
31
+ return selector;
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+
37
+ export async function detectPasswordField(page: Page): Promise<string | null> {
38
+ for (const selector of PASSWORD_SELECTORS) {
39
+ const element = await page.$(selector);
40
+ if (element && await element.isVisible()) {
41
+ return selector;
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+
47
+ export async function detectSubmitButton(page: Page): Promise<string | null> {
48
+ for (const selector of SUBMIT_SELECTORS) {
49
+ try {
50
+ const element = await page.$(selector);
51
+ if (element && await element.isVisible()) {
52
+ return selector;
53
+ }
54
+ } catch {
55
+ // Some selectors might not be valid, continue
56
+ }
57
+ }
58
+ return null;
59
+ }
@@ -0,0 +1,110 @@
1
+ import keytar from 'keytar';
2
+ import { z } from 'zod';
3
+ import type { RPConfig } from '../types/index.js';
4
+ import { logAuditEvent } from './audit.js';
5
+ import { checkRateLimit } from './ratelimit.js';
6
+
7
+ const SERVICE_NAME = 'agent-vault';
8
+
9
+ // Zod schema for strict validation of stored credentials
10
+ const SelectorsSchema = z.object({
11
+ username: z.string().min(1),
12
+ password: z.string().min(1),
13
+ submit: z.string().optional(),
14
+ });
15
+
16
+ const CredentialsSchema = z.object({
17
+ username: z.string(),
18
+ password: z.string(),
19
+ });
20
+
21
+ const RPConfigSchema = z.object({
22
+ origin: z.string().url(),
23
+ selectors: SelectorsSchema,
24
+ credentials: CredentialsSchema,
25
+ });
26
+
27
+ /**
28
+ * Validate RPConfig data against schema
29
+ */
30
+ function validateRPConfig(data: unknown): RPConfig | null {
31
+ const result = RPConfigSchema.safeParse(data);
32
+ if (!result.success) {
33
+ return null;
34
+ }
35
+ return result.data;
36
+ }
37
+
38
+ export async function storeRP(config: RPConfig): Promise<void> {
39
+ // Rate limit credential storage
40
+ await checkRateLimit('store_credentials');
41
+
42
+ // Validate config before storing
43
+ const validated = validateRPConfig(config);
44
+ if (!validated) {
45
+ throw new Error('Invalid credential configuration');
46
+ }
47
+
48
+ await keytar.setPassword(SERVICE_NAME, config.origin, JSON.stringify(validated));
49
+ await logAuditEvent('credential_stored', { origin: config.origin });
50
+ }
51
+
52
+ export async function getRP(origin: string): Promise<RPConfig | null> {
53
+ try {
54
+ // Rate limit credential retrieval
55
+ await checkRateLimit('get_credentials');
56
+
57
+ const data = await keytar.getPassword(SERVICE_NAME, origin);
58
+ if (!data) {
59
+ await logAuditEvent('credential_retrieved', { origin, success: false });
60
+ return null;
61
+ }
62
+
63
+ let parsed: unknown;
64
+ try {
65
+ parsed = JSON.parse(data);
66
+ } catch {
67
+ // Corrupted JSON data
68
+ await logAuditEvent('credential_retrieved', {
69
+ origin,
70
+ details: 'Corrupted data',
71
+ success: false,
72
+ });
73
+ return null;
74
+ }
75
+
76
+ // Validate with zod schema
77
+ const validated = validateRPConfig(parsed);
78
+ if (!validated) {
79
+ await logAuditEvent('credential_retrieved', {
80
+ origin,
81
+ details: 'Schema validation failed',
82
+ success: false,
83
+ });
84
+ return null;
85
+ }
86
+
87
+ await logAuditEvent('credential_retrieved', { origin, success: true });
88
+ return validated;
89
+ } catch (error) {
90
+ // Don't expose internal errors - use generic message
91
+ if (error instanceof Error && error.message.includes('Rate limit')) {
92
+ throw error; // Re-throw rate limit errors
93
+ }
94
+ await logAuditEvent('credential_retrieved', {
95
+ origin,
96
+ details: 'Internal error',
97
+ success: false,
98
+ });
99
+ return null;
100
+ }
101
+ }
102
+
103
+ export async function deleteRP(origin: string): Promise<boolean> {
104
+ // Rate limit credential deletion
105
+ await checkRateLimit('delete_credentials');
106
+
107
+ const result = await keytar.deletePassword(SERVICE_NAME, origin);
108
+ await logAuditEvent('credential_deleted', { origin, success: result });
109
+ return result;
110
+ }
@@ -0,0 +1,90 @@
1
+ import { getConfigValue } from './config.js';
2
+
3
+ export function extractOrigin(url: string): string {
4
+ const parsed = new URL(url);
5
+ return parsed.origin;
6
+ }
7
+
8
+ /**
9
+ * Check if origin uses HTTPS (secure)
10
+ */
11
+ export function isSecureProtocol(origin: string): boolean {
12
+ try {
13
+ const url = new URL(origin);
14
+ return url.protocol === 'https:';
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Check if origin uses HTTP (insecure)
22
+ */
23
+ export function isHttpProtocol(origin: string): boolean {
24
+ try {
25
+ const url = new URL(origin);
26
+ return url.protocol === 'http:';
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ export function isValidOrigin(origin: string): boolean {
33
+ try {
34
+ const url = new URL(origin);
35
+ return url.protocol === 'https:' || url.protocol === 'http:';
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ export interface OriginValidationOptions {
42
+ allowHttp?: boolean;
43
+ }
44
+
45
+ /**
46
+ * Validate an origin with security checks.
47
+ * By default, requires HTTPS to prevent credentials from being sent in plaintext.
48
+ */
49
+ export async function validateOriginSecurity(
50
+ origin: string,
51
+ options: OriginValidationOptions = {}
52
+ ): Promise<void> {
53
+ // Check config for allowHttp setting
54
+ const configAllowHttp = await getConfigValue('allowHttp');
55
+ const allowHttp = options.allowHttp ?? configAllowHttp === 'true';
56
+
57
+ // Check HTTPS requirement
58
+ if (!allowHttp && isHttpProtocol(origin)) {
59
+ throw new Error(
60
+ 'HTTP origins are not allowed by default. Credentials would be sent in plaintext. ' +
61
+ 'Use --allow-http flag or set config allowHttp=true to override.'
62
+ );
63
+ }
64
+
65
+ // Validate basic origin format
66
+ if (!isValidOrigin(origin)) {
67
+ throw new Error('Invalid origin format');
68
+ }
69
+ }
70
+
71
+ export function extractAndValidateOrigin(url: string): string {
72
+ const origin = extractOrigin(url);
73
+ if (!isValidOrigin(origin)) {
74
+ throw new Error('Invalid page origin');
75
+ }
76
+ return origin;
77
+ }
78
+
79
+ /**
80
+ * Extract and validate origin with full security checks.
81
+ * Use this for operations that store or retrieve credentials.
82
+ */
83
+ export async function extractAndValidateOriginSecure(
84
+ url: string,
85
+ options: OriginValidationOptions = {}
86
+ ): Promise<string> {
87
+ const origin = extractOrigin(url);
88
+ await validateOriginSecurity(origin, options);
89
+ return origin;
90
+ }