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;