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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user