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
+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;