diff --git a/LICENSE b/LICENSE index ef467d2..d546866 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,38 @@ -MIT License +Business Source License 1.1 -Copyright (c) 2025 Silent Send Contributors +Licensor: Silent Send Contributors +Licensed Work: Silent Send browser extension +Change Date: March 26, 2030 +Change License: MIT -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Terms -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +The Licensor hereby grants you the right to copy, modify, create +derivative works, redistribute, and make non-production use of the +Licensed Work. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +The Licensor hereby grants you the right to make production use of +the Licensed Work for personal, non-commercial purposes. + +For commercial use, you must obtain a commercial license from the +Licensor. Contact: [your-email-here] + +Effective on the Change Date, the Licensor hereby grants you rights +under the terms of the Change License, and the rights granted above +terminate. + +If your use of the Licensed Work does not comply with the +requirements currently in effect as described in this License, you +must purchase a commercial license from the Licensor, or you must +refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and +derivative works of the Licensed Work, are subject to this License. + +THE LICENSED WORK IS PROVIDED "AS IS". THE LICENSOR HEREBY DISCLAIMS +ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE LICENSOR BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +LICENSED WORK OR THE USE OR OTHER DEALINGS IN THE LICENSED WORK. diff --git a/README.md b/README.md index 097dbd8..0695740 100644 --- a/README.md +++ b/README.md @@ -290,4 +290,4 @@ src/ ## License -[MIT](LICENSE) — use it for anything, commercial or personal, modify it, redistribute it, relicense it. Just keep the copyright notice in copies of the code. +[Business Source License 1.1](LICENSE) — free for personal, non-commercial use. Commercial use requires a paid license. The code automatically converts to MIT on March 26, 2030. diff --git a/package.json b/package.json index c2930a4..4ded9a7 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "silent-send", "version": "0.3.1", "private": true, - "license": "MIT", + "license": "BSL-1.1", "description": "Browser extension that substitutes personal data before sending to AI services", "scripts": { "build:chrome": "./build.sh chrome", diff --git a/src/lib/crypto.js b/src/lib/crypto.js new file mode 100644 index 0000000..7443539 --- /dev/null +++ b/src/lib/crypto.js @@ -0,0 +1,92 @@ +/** + * Silent Send - Crypto Module + * + * AES-256-GCM encryption with PBKDF2 key derivation. + * Used for encrypted export/import of user data. + */ + +const SALT_LENGTH = 16; +const IV_LENGTH = 12; +const ITERATIONS = 100000; + +async function deriveKey(password, salt) { + const encoder = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(password), + 'PBKDF2', + false, + ['deriveKey'] + ); + + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: ITERATIONS, + hash: 'SHA-256', + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); +} + +const SilentSendCrypto = { + /** + * Encrypt data with a password. + * Returns a base64 string containing salt + iv + ciphertext. + */ + async encrypt(data, password) { + const encoder = new TextEncoder(); + const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH)); + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); + const key = await deriveKey(password, salt); + + const plaintext = encoder.encode(JSON.stringify(data)); + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + plaintext + ); + + // Combine: salt (16) + iv (12) + ciphertext + const combined = new Uint8Array(salt.length + iv.length + ciphertext.byteLength); + combined.set(salt, 0); + combined.set(iv, salt.length); + combined.set(new Uint8Array(ciphertext), salt.length + iv.length); + + // Base64 encode + return btoa(String.fromCharCode(...combined)); + }, + + /** + * Decrypt data with a password. + * Takes the base64 string from encrypt(). + */ + async decrypt(encryptedBase64, password) { + const combined = Uint8Array.from(atob(encryptedBase64), c => c.charCodeAt(0)); + + const salt = combined.slice(0, SALT_LENGTH); + const iv = combined.slice(SALT_LENGTH, SALT_LENGTH + IV_LENGTH); + const ciphertext = combined.slice(SALT_LENGTH + IV_LENGTH); + + const key = await deriveKey(password, salt); + + try { + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + ciphertext + ); + + const decoder = new TextDecoder(); + return JSON.parse(decoder.decode(plaintext)); + } catch (e) { + throw new Error('Wrong password or corrupted data'); + } + }, +}; + +export default SilentSendCrypto; diff --git a/src/options/options.html b/src/options/options.html index 2a9312f..94d84fc 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -66,13 +66,24 @@ +
+

Transfer Data

+

Export all your identities, mappings, and settings to move between browsers. Encrypted exports require a password to decrypt.

+
+ + + + +
+
+

Mappings

Manage all your substitution rules. Longer matches take priority.

- - + +
diff --git a/src/options/options.js b/src/options/options.js index b3fd320..0f599a6 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -1,4 +1,5 @@ import Storage from '../lib/storage.js'; +import SilentSendCrypto from '../lib/crypto.js'; import api from '../lib/browser-polyfill.js'; let mappings = []; @@ -19,6 +20,12 @@ document.addEventListener('DOMContentLoaded', async () => { renderDomains(); renderLog(); + // Transfer data + $('#btnExportAll').addEventListener('click', exportAllPlain); + $('#btnExportEncrypted').addEventListener('click', exportAllEncrypted); + $('#btnImportAll').addEventListener('click', () => $('#fileImportAll').click()); + $('#fileImportAll').addEventListener('change', importAll); + // Custom domains $('#btnAddDomain').addEventListener('click', addDomain); $('#newDomain').addEventListener('keydown', (e) => { @@ -271,6 +278,106 @@ function renderDomains() { }); } +// --- Transfer Data (Export/Import All) --- + +async function getAllData() { + const result = await api.storage.local.get(null); // get everything + return { + version: '1', + exportedAt: new Date().toISOString(), + identity: result.ss_identity || {}, + mappings: result.ss_mappings || [], + settings: result.ss_settings || {}, + }; +} + +function downloadFile(content, filename) { + const blob = new Blob([content], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +async function exportAllPlain() { + const data = await getAllData(); + downloadFile(JSON.stringify(data, null, 2), 'silent-send-backup.json'); +} + +async function exportAllEncrypted() { + const password = prompt('Set a password for this backup:'); + if (!password) return; + const confirm = prompt('Confirm password:'); + if (password !== confirm) { + alert('Passwords do not match.'); + return; + } + + const data = await getAllData(); + try { + const encrypted = await SilentSendCrypto.encrypt(data, password); + const wrapper = JSON.stringify({ encrypted: true, data: encrypted }); + downloadFile(wrapper, 'silent-send-backup.ssbackup'); + alert('Encrypted backup saved. You will need the password to import it.'); + } catch (e) { + alert('Encryption failed: ' + e.message); + } +} + +async function importAll(e) { + const file = e.target.files[0]; + if (!file) return; + + try { + const text = await file.text(); + const parsed = JSON.parse(text); + let data; + + if (parsed.encrypted) { + // Encrypted backup + const password = prompt('Enter the password for this backup:'); + if (!password) return; + try { + data = await SilentSendCrypto.decrypt(parsed.data, password); + } catch (err) { + alert('Wrong password or corrupted file.'); + return; + } + } else { + // Plain backup + data = parsed; + } + + if (!data.version) { + alert('Not a valid Silent Send backup file.'); + return; + } + + if (!confirm('This will replace all your current data. Continue?')) return; + + // Restore + if (data.identity) await api.storage.local.set({ ss_identity: data.identity }); + if (data.mappings) await api.storage.local.set({ ss_mappings: data.mappings }); + if (data.settings) await api.storage.local.set({ ss_settings: data.settings }); + + // Refresh UI + mappings = await Storage.getMappings(); + settings = await Storage.getSettings(); + renderMappings(); + renderDomains(); + renderLog(); + + alert('Import complete. Reload the extension for changes to take effect.'); + } catch (err) { + alert('Failed to import: ' + err.message); + } + + // Reset file input + e.target.value = ''; +} + function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str;