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
|
Terms
|
||||||
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:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
The Licensor hereby grants you the right to copy, modify, create
|
||||||
copies or substantial portions of the Software.
|
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
|
The Licensor hereby grants you the right to make production use of
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
the Licensed Work for personal, non-commercial purposes.
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
For commercial use, you must obtain a commercial license from the
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
Licensor. Contact: [your-email-here]
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
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
|
## 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",
|
"name": "silent-send",
|
||||||
"version": "0.3.1",
|
"version": "0.3.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "BSL-1.1",
|
||||||
"description": "Browser extension that substitutes personal data before sending to AI services",
|
"description": "Browser extension that substitutes personal data before sending to AI services",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:chrome": "./build.sh chrome",
|
"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>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="section">
|
||||||
<h2>Mappings</h2>
|
<h2>Mappings</h2>
|
||||||
<p class="section-desc">Manage all your substitution rules. Longer matches take priority.</p>
|
<p class="section-desc">Manage all your substitution rules. Longer matches take priority.</p>
|
||||||
|
|
||||||
<div class="bulk-actions">
|
<div class="bulk-actions">
|
||||||
<button class="btn" id="btnExport">Export JSON</button>
|
<button class="btn" id="btnExport">Export Mappings</button>
|
||||||
<button class="btn" id="btnImport">Import JSON</button>
|
<button class="btn" id="btnImport">Import Mappings</button>
|
||||||
<input type="file" id="fileImport" accept=".json" hidden>
|
<input type="file" id="fileImport" accept=".json" hidden>
|
||||||
<button class="btn btn-danger" id="btnClearAll">Clear All</button>
|
<button class="btn btn-danger" id="btnClearAll">Clear All</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Storage from '../lib/storage.js';
|
import Storage from '../lib/storage.js';
|
||||||
|
import SilentSendCrypto from '../lib/crypto.js';
|
||||||
import api from '../lib/browser-polyfill.js';
|
import api from '../lib/browser-polyfill.js';
|
||||||
|
|
||||||
let mappings = [];
|
let mappings = [];
|
||||||
@@ -19,6 +20,12 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
renderDomains();
|
renderDomains();
|
||||||
renderLog();
|
renderLog();
|
||||||
|
|
||||||
|
// Transfer data
|
||||||
|
$('#btnExportAll').addEventListener('click', exportAllPlain);
|
||||||
|
$('#btnExportEncrypted').addEventListener('click', exportAllEncrypted);
|
||||||
|
$('#btnImportAll').addEventListener('click', () => $('#fileImportAll').click());
|
||||||
|
$('#fileImportAll').addEventListener('change', importAll);
|
||||||
|
|
||||||
// Custom domains
|
// Custom domains
|
||||||
$('#btnAddDomain').addEventListener('click', addDomain);
|
$('#btnAddDomain').addEventListener('click', addDomain);
|
||||||
$('#newDomain').addEventListener('keydown', (e) => {
|
$('#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) {
|
function escapeHtml(str) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = str;
|
div.textContent = str;
|
||||||
|
|||||||
Reference in New Issue
Block a user