feat: BSL license + encrypted export/import for cross-browser transfer

License: Changed from MIT to BSL 1.1. Free for personal use,
commercial use requires a paid license. Auto-converts to MIT
on March 26, 2030.

Export/Import: Options page now has "Transfer Data" section:
- Export All (plain) — JSON file with all identities, mappings, settings
- Export Encrypted — AES-256-GCM with PBKDF2 password derivation,
  saved as .ssbackup file
- Import — handles both plain and encrypted backups, prompts for
  password if encrypted

Crypto uses Web Crypto API (browser-native, no dependencies):
100k PBKDF2 iterations, random salt + IV per export.

https://claude.ai/code/session_01Dvgwe7XMoSxnWXkih8p1Cw
This commit is contained in:
Claude
2026-03-26 04:30:48 +00:00
parent b89333076f
commit 260b713c94
6 changed files with 248 additions and 21 deletions
+34 -17
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
+92
View File
@@ -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;
+13 -2
View File
@@ -66,13 +66,24 @@
</div>
</section>
<section class="section">
<h2>Transfer Data</h2>
<p class="section-desc">Export all your identities, mappings, and settings to move between browsers. Encrypted exports require a password to decrypt.</p>
<div class="bulk-actions">
<button class="btn" id="btnExportAll">Export All (plain)</button>
<button class="btn" id="btnExportEncrypted">Export Encrypted</button>
<button class="btn" id="btnImportAll">Import</button>
<input type="file" id="fileImportAll" accept=".json,.ssbackup" hidden>
</div>
</section>
<section class="section">
<h2>Mappings</h2>
<p class="section-desc">Manage all your substitution rules. Longer matches take priority.</p>
<div class="bulk-actions">
<button class="btn" id="btnExport">Export JSON</button>
<button class="btn" id="btnImport">Import JSON</button>
<button class="btn" id="btnExport">Export Mappings</button>
<button class="btn" id="btnImport">Import Mappings</button>
<input type="file" id="fileImport" accept=".json" hidden>
<button class="btn btn-danger" id="btnClearAll">Clear All</button>
</div>
+107
View File
@@ -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;