diff options
| author | Agando <roduccomm@gmail.com> | 2026-05-25 18:02:02 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-05-25 18:02:02 +0200 |
| commit | 4baa484b84c5184286cac2743575251454a5ee2b (patch) | |
| tree | cb9002f93644f363d0f35831dc97e6492bbb0ed6 /modules/core/secret-vault.js | |
auto: 2026-05-25_18:02 changes
Diffstat (limited to 'modules/core/secret-vault.js')
| -rw-r--r-- | modules/core/secret-vault.js | 459 |
1 files changed, 459 insertions, 0 deletions
diff --git a/modules/core/secret-vault.js b/modules/core/secret-vault.js new file mode 100644 index 0000000..e62192a --- /dev/null +++ b/modules/core/secret-vault.js @@ -0,0 +1,459 @@ +// Extracted from newOS4 index.html — SecretVault +class SecretVault { + constructor() { + this.commands = {}; + this.terminal = null; + this._key = null; + this._prefix = 'webx11_secret_'; + this._saltKey = 'webx11_secret_salt'; + this._verifyName = 'secret_vault_verified'; + this._openModeKey = 'webx11_secret_open_key'; + } + + getHelpInfo() { + return { + 'secret-set': 'secret-set <name> <value> — Encrypt and store a secret', + 'secret-get': 'secret-get <name> — Decrypt and display a secret value', + 'secret-list': 'secret-list — List all stored secret names', + 'secret-del': 'secret-del <name> — Remove a stored secret', + 'secret-lock': 'secret-lock — Lock the vault (clear derived key from memory)', + 'secret-unlock': 'secret-unlock <pin> — Unlock the vault with a PIN code', + 'secret-setpin': 'secret-setpin <pin> — Set or change the vault PIN code', + 'secret-status': 'secret-status — Show vault status (locked/unlocked/pin set/open mode)', + }; + } + + registerCommands() { + this.commands['secret-set'] = (args) => this._cmdSet(args); + this.commands['secret-get'] = (args) => this._cmdGet(args); + this.commands['secret-list'] = () => this._cmdList(); + this.commands['secret-del'] = (args) => this._cmdDel(args); + this.commands['secret-lock'] = () => this._cmdLock(); + this.commands['secret-unlock'] = (args) => this._cmdUnlock(args); + this.commands['secret-setpin'] = (args) => this._cmdSetPin(args); + this.commands['secret-status'] = () => this._cmdStatus(); + } + + // ---------- Core API ---------- + + isPinSet() { + return localStorage.getItem(this._saltKey) !== null; + } + + isLocked() { + // Locked only if PIN is set but key is not derived in memory + return this.isPinSet() && this._key === null; + } + + async setPin(pin) { + if (!pin || typeof pin !== 'string' || pin.length === 0) { + return { error: 'PIN must be a non-empty string' }; + } + + // Generate new salt + const salt = this._generateSalt(); + localStorage.setItem(this._saltKey, this._arrayToBase64(salt)); + + // Derive new key from the new PIN + const newKey = await this._deriveKeyFromPin(pin, salt); + + if (this.isPinSet() && this._key !== null) { + // Re-encrypt all existing secrets with new key + await this._reEncryptAll(newKey); + } + + // Store the new key in memory + this._key = newKey; + + // Store a verification token so we know the PIN works + await this._encryptWithKey(this._verifyName, 'verified', this._key); + + return { success: true }; + } + + lock() { + this._key = null; + return { success: true }; + } + + async unlock(pin) { + if (!pin || typeof pin !== 'string' || pin.length === 0) { + return { error: 'PIN must be a non-empty string' }; + } + + if (!this.isPinSet()) { + return { error: 'No PIN has been set. Use secret-setpin first.' }; + } + + const saltBase64 = localStorage.getItem(this._saltKey); + if (!saltBase64) { + return { error: 'Salt not found. Vault may be corrupted.' }; + } + + const salt = this._base64ToArray(saltBase64); + const key = await this._deriveKeyFromPin(pin, salt); + + // Verify PIN by checking the verification token + const verifyData = await this._decryptWithKey(this._verifyName, key); + if (verifyData === null) { + return { error: 'Invalid PIN' }; + } + + this._key = key; + return { success: true }; + } + + async encrypt(name, value) { + if (this.isLocked()) { + return { error: 'Vault is locked' }; + } + + // Ensure key is available + let key = this._key; + if (!key) { + // Open mode: use or generate a session-like key + key = await this._getOpenModeKey(); + } + + const stringValue = typeof value === 'string' ? value : JSON.stringify(value); + return this._encryptWithKey(name, stringValue, key); + } + + async decrypt(name) { + if (this.isLocked()) { + return null; + } + + let key = this._key; + if (!key) { + key = await this._getOpenModeKey(); + if (!key) return null; + } + + return this._decryptWithKey(name, key); + } + + remove(name) { + localStorage.removeItem(this._prefix + name); + return { success: true }; + } + + list() { + const names = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith(this._prefix)) { + names.push(key.substring(this._prefix.length)); + } + } + return names; + } + + has(name) { + return localStorage.getItem(this._prefix + name) !== null; + } + + /** + * Scans text for any stored secret values and replaces them with '[REDACTED]'. + * Used by terminal and AI context to prevent leaking secrets in output. + */ + async redactSecret(text) { + if (!text || typeof text !== 'string') return text; + + const names = this.list(); + if (names.length === 0) return text; + + let redacted = text; + + for (const name of names) { + try { + const value = await this.decrypt(name); + if (value && typeof value === 'string' && value.length > 0) { + // Escape special regex characters in the secret value + const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + try { + const regex = new RegExp(escaped, 'g'); + redacted = redacted.replace(regex, '[REDACTED]'); + } catch (e) { + // If regex fails (e.g., complex Unicode), try manual replacement + while (redacted.includes(value)) { + redacted = redacted.replace(value, '[REDACTED]'); + } + } + } + } catch (e) { + // Skip secrets that can't be decrypted + } + } + + return redacted; + } + + // ---------- Internal crypto methods ---------- + + _generateSalt() { + const salt = new Uint8Array(16); + crypto.getRandomValues(salt); + return salt; + } + + _generateIV() { + const iv = new Uint8Array(12); + crypto.getRandomValues(iv); + return iv; + } + + _arrayToBase64(array) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(array))); + } + + _base64ToArray(base64) { + const binary = atob(base64); + const array = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + array[i] = binary.charCodeAt(i); + } + return array; + } + + async _deriveKeyFromPin(pin, salt) { + const encoder = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(pin), + 'PBKDF2', + false, + ['deriveKey'] + ); + + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: salt, + iterations: 100000, + hash: 'SHA-256', + }, + keyMaterial, + { + name: 'AES-GCM', + length: 256, + }, + false, + ['encrypt', 'decrypt'] + ); + } + + async _getOpenModeKey() { + // In open mode (no PIN set), we use a persistent 32-char hex key in localStorage + // This is less secure than PIN mode but better than plaintext + let keyHex = localStorage.getItem(this._openModeKey); + if (!keyHex) { + keyHex = this._generateOpenKey(); + localStorage.setItem(this._openModeKey, keyHex); + } + return this._hexKeyToCryptoKey(keyHex); + } + + _generateOpenKey() { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); + } + + async _hexKeyToCryptoKey(hex) { + const bytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + bytes[i] = parseInt(hex.substr(i * 2, 2), 16); + } + return crypto.subtle.importKey( + 'raw', + bytes, + { + name: 'AES-GCM', + length: 256, + }, + false, + ['encrypt', 'decrypt'] + ); + } + + async _encryptWithKey(name, value, key) { + try { + const encoder = new TextEncoder(); + const data = encoder.encode(value); + const iv = this._generateIV(); + + const encrypted = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: iv, + }, + key, + data + ); + + const encryptedArray = new Uint8Array(encrypted); + const storageData = JSON.stringify({ + iv: this._arrayToBase64(iv), + data: this._arrayToBase64(encryptedArray), + }); + + localStorage.setItem(this._prefix + name, storageData); + return { success: true }; + } catch (e) { + return { error: 'Encryption failed: ' + e.message }; + } + } + + async _decryptWithKey(name, key) { + try { + const storageData = localStorage.getItem(this._prefix + name); + if (!storageData) return null; + + const parsed = JSON.parse(storageData); + if (!parsed.iv || !parsed.data) return null; + + const iv = this._base64ToArray(parsed.iv); + const encryptedData = this._base64ToArray(parsed.data); + + const decrypted = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: iv, + }, + key, + encryptedData + ); + + const decoder = new TextDecoder(); + return decoder.decode(decrypted); + } catch (e) { + return null; + } + } + + async _reEncryptAll(newKey) { + const names = this.list(); + const oldKey = this._key; + + for (const name of names) { + // Skip verification token — it's regenerated in setPin + if (name === this._verifyName) continue; + + const plaintext = await this._decryptWithKey(name, oldKey); + if (plaintext !== null) { + await this._encryptWithKey(name, plaintext, newKey); + } + } + + // Update verification token with new key + await this._encryptWithKey(this._verifyName, 'verified', newKey); + } + + // ---------- Command handlers ---------- + + async _cmdSet(args) { + if (args.length < 2) { + this.terminal.print('Usage: secret-set <name> <value>', 'warning'); + return; + } + const name = args[0]; + const value = args.slice(1).join(' '); + const result = await this.encrypt(name, value); + if (result.error) { + this.terminal.print(result.error, 'error'); + } else { + this.terminal.print(`Secret '${name}' saved`, 'success'); + } + } + + async _cmdGet(args) { + if (!args[0]) { + this.terminal.print('Usage: secret-get <name>', 'warning'); + return; + } + const value = await this.decrypt(args[0]); + if (value === null) { + if (this.isLocked()) { + this.terminal.print('Vault is locked. Use secret-unlock <pin>', 'warning'); + } else { + this.terminal.print(`Secret '${args[0]}' not found`, 'error'); + } + } else { + this.terminal.print(`Secret '${args[0]}': ${value}`, 'info'); + } + } + + _cmdList() { + const names = this.list(); + if (names.length === 0) { + this.terminal.print('No secrets stored', 'info'); + return; + } + this.terminal.print('=== Stored Secrets ===', 'info'); + names.forEach(n => { + // Skip internal verification token + if (n === this._verifyName) return; + this.terminal.print(` - ${n}`); + }); + } + + _cmdDel(args) { + if (!args[0]) { + this.terminal.print('Usage: secret-del <name>', 'warning'); + return; + } + const result = this.remove(args[0]); + if (result.success) { + this.terminal.print(`Secret '${args[0]}' deleted`, 'success'); + } + } + + _cmdLock() { + const result = this.lock(); + if (result.success) { + this.terminal.print('Vault locked', 'success'); + } + } + + async _cmdUnlock(args) { + if (!args[0]) { + this.terminal.print('Usage: secret-unlock <pin>', 'warning'); + return; + } + const result = await this.unlock(args[0]); + if (result.error) { + this.terminal.print(result.error, 'error'); + } else { + this.terminal.print('Vault unlocked', 'success'); + } + } + + async _cmdSetPin(args) { + if (!args[0]) { + this.terminal.print('Usage: secret-setpin <pin>', 'warning'); + return; + } + const result = await this.setPin(args[0]); + if (result.error) { + this.terminal.print(result.error, 'error'); + } else { + this.terminal.print('PIN set successfully. Vault is now in PIN-protected mode.', 'success'); + } + } + + _cmdStatus() { + const pinSet = this.isPinSet(); + const locked = this.isLocked(); + const hasKey = this._key !== null; + + this.terminal.print('=== Secret Vault Status ===', 'info'); + this.terminal.print(`PIN set: ${pinSet ? 'Yes' : 'No'}`, pinSet ? 'success' : 'info'); + if (pinSet) { + this.terminal.print(`Vault: ${locked ? '🔒 Locked' : '🔓 Unlocked'}`, locked ? 'warning' : 'success'); + } else { + this.terminal.print(`Mode: Open (no PIN — using persistent key)`, 'info'); + } + this.terminal.print(`Secrets stored: ${this.list().filter(n => n !== this._verifyName).length}`, 'info'); + } +} |