aboutsummaryrefslogtreecommitdiff
path: root/modules/core/secret-vault.js
diff options
context:
space:
mode:
Diffstat (limited to 'modules/core/secret-vault.js')
-rw-r--r--modules/core/secret-vault.js459
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');
+ }
+}