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:
@@ -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