feat: recall-on-restore, logout, and mail/contact export-import
This commit is contained in:
parent
a3378def50
commit
3576e48b99
4 changed files with 216 additions and 16 deletions
|
|
@ -41,6 +41,8 @@
|
||||||
<button id="create-identity">create a new identity</button>
|
<button id="create-identity">create a new identity</button>
|
||||||
<span>or restore from a saved seed:</span>
|
<span>or restore from a saved seed:</span>
|
||||||
<input id="restore-seed" placeholder="64 hex characters" spellcheck="false">
|
<input id="restore-seed" placeholder="64 hex characters" spellcheck="false">
|
||||||
|
<input id="restore-address" placeholder="alice@example.org (if already registered)"
|
||||||
|
spellcheck="false">
|
||||||
<button id="restore-identity">restore</button>
|
<button id="restore-identity">restore</button>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -128,6 +130,19 @@
|
||||||
<div id="identity-card"></div>
|
<div id="identity-card"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>backup</h3>
|
||||||
|
<p class="hint">Mail, once fetched, exists only here — the server deletes it as it's picked up
|
||||||
|
(SPEC.md §5). This backs up the local inbox, sent mail, contacts and pinned servers into one
|
||||||
|
file. It never includes the seed: that has its own reveal-and-copy flow above, meant for a
|
||||||
|
password manager, not a downloadable file.</p>
|
||||||
|
<div class="backup-row">
|
||||||
|
<button type="button" id="export-data">export</button>
|
||||||
|
<button type="button" id="import-data-trigger">import</button>
|
||||||
|
<input id="import-data" type="file" accept="application/json" hidden>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<footer><button id="settings-close">close</button></footer>
|
<footer><button id="settings-close">close</button></footer>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@ $("create-identity").onclick = () => {
|
||||||
openSettings();
|
openSettings();
|
||||||
};
|
};
|
||||||
|
|
||||||
$("restore-identity").onclick = () => {
|
$("restore-identity").onclick = async () => {
|
||||||
const text = $("restore-seed").value.trim();
|
const text = $("restore-seed").value.trim();
|
||||||
if (!/^[0-9a-fA-F]{64}$/.test(text)) return status("a seed is 64 hex characters", true);
|
if (!/^[0-9a-fA-F]{64}$/.test(text)) return status("a seed is 64 hex characters", true);
|
||||||
const seedBytes = Uint8Array.from(text.match(/../g), b => parseInt(b, 16));
|
const seedBytes = Uint8Array.from(text.match(/../g), b => parseInt(b, 16));
|
||||||
|
|
@ -167,7 +167,23 @@ $("restore-identity").onclick = () => {
|
||||||
return status(err.message, true);
|
return status(err.message, true);
|
||||||
}
|
}
|
||||||
showApp();
|
showApp();
|
||||||
status("identity restored.");
|
// The address field is optional: restoring must succeed on its own even if
|
||||||
|
// this fails (server not pinned yet, offline, typo) — recall can always be
|
||||||
|
// retried from settings afterward.
|
||||||
|
const addressText = $("restore-address").value.trim();
|
||||||
|
let message = "identity restored.", warn = false;
|
||||||
|
if (addressText) {
|
||||||
|
try {
|
||||||
|
const addr = await recallAccount(addressText);
|
||||||
|
message += `\nrecalled ${addr.short}`;
|
||||||
|
renderAccount();
|
||||||
|
renderSettings();
|
||||||
|
} catch (err) {
|
||||||
|
message += `\ncouldn't recall that address: ${err.message}`;
|
||||||
|
warn = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status(message, warn);
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- header ---------------------------------------------------------------------
|
// --- header ---------------------------------------------------------------------
|
||||||
|
|
@ -824,7 +840,15 @@ function renderSettings() {
|
||||||
"Rotating issues a fresh key with a signed certificate proving it replaces this one. " +
|
"Rotating issues a fresh key with a signed certificate proving it replaces this one. " +
|
||||||
"Contacts accept the change from that chain, and the old seed is kept, since mail sealed " +
|
"Contacts accept the change from that chain, and the old seed is kept, since mail sealed " +
|
||||||
"to it is readable with nothing else. Rotation is not revocation (SPEC.md \u00a77)." }),
|
"to it is readable with nothing else. Rotation is not revocation (SPEC.md \u00a77)." }),
|
||||||
el("button", { type: "button", textContent: "rotate identity", onclick: rotateIdentity })),
|
el("button", { type: "button", textContent: "rotate identity", onclick: rotateIdentity }),
|
||||||
|
el("p", { className: "hint", textContent:
|
||||||
|
"Log out to remove this identity from this browser entirely: the seed, pins, contacts and " +
|
||||||
|
"every cached message. Without a backup of the seed there is no way back in — same as any " +
|
||||||
|
"other loss of it. Fetching deletes from the server once acknowledged (SPEC.md §5), so a " +
|
||||||
|
"message already fetched here has no other copy anywhere; anything still unfetched stays " +
|
||||||
|
"on the server and comes back on the next fetch after logging back in." }),
|
||||||
|
el("button", { type: "button", className: "danger", textContent: "log out & erase this browser",
|
||||||
|
onclick: logout })),
|
||||||
].filter(node => node != null));
|
].filter(node => node != null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -887,14 +911,12 @@ $("register-form").onsubmit = async (event) => {
|
||||||
// *different* browser registered it under — "account" is local-only state,
|
// *different* browser registered it under — "account" is local-only state,
|
||||||
// never asked of the server. This binds it without REGISTER: RESOLVE the
|
// never asked of the server. This binds it without REGISTER: RESOLVE the
|
||||||
// address and require it name this exact key, so a typo or someone else's
|
// address and require it name this exact key, so a typo or someone else's
|
||||||
// address can't silently misfile outgoing Reply-To / fetch under it.
|
// address can't silently misfile outgoing Reply-To / fetch under it. Shared
|
||||||
$("recall-form").onsubmit = async (event) => {
|
// by the settings recall form and the optional address field on restore.
|
||||||
event.preventDefault();
|
async function recallAccount(text) {
|
||||||
const form = event.target;
|
|
||||||
try {
|
|
||||||
const me = store.identity();
|
const me = store.identity();
|
||||||
if (!me) throw new SmolError("no identity yet");
|
if (!me) throw new SmolError("no identity yet");
|
||||||
const addr = parseAddress(form.address.value.trim());
|
const addr = parseAddress(text.trim());
|
||||||
const opened = await connect(addr, { requirePin: true });
|
const opened = await connect(addr, { requirePin: true });
|
||||||
let current;
|
let current;
|
||||||
try {
|
try {
|
||||||
|
|
@ -905,6 +927,15 @@ $("recall-form").onsubmit = async (event) => {
|
||||||
if (!timingSafeEqual(current, me.publicKey))
|
if (!timingSafeEqual(current, me.publicKey))
|
||||||
throw new SmolError(`${addr.short} resolves to a different key — not this identity`);
|
throw new SmolError(`${addr.short} resolves to a different key — not this identity`);
|
||||||
store.setAccount(addr);
|
store.setAccount(addr);
|
||||||
|
return addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
$("recall-form").onsubmit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.target;
|
||||||
|
try {
|
||||||
|
const me = store.identity();
|
||||||
|
const addr = await recallAccount(form.address.value);
|
||||||
status(`recalled ${addr.short}\nshare: ${addr.uri(me.publicKey)}`);
|
status(`recalled ${addr.short}\nshare: ${addr.uri(me.publicKey)}`);
|
||||||
renderAccount();
|
renderAccount();
|
||||||
renderSettings();
|
renderSettings();
|
||||||
|
|
@ -914,6 +945,40 @@ $("recall-form").onsubmit = async (event) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$("export-data").onclick = async () => {
|
||||||
|
const data = await store.exportData();
|
||||||
|
const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const stamp = new Date(data.exportedAt).toISOString().slice(0, 10);
|
||||||
|
Object.assign(document.createElement("a"), { href: url, download: `gsmol-export-${stamp}.json` }).click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
status(`exported ${data.inbox.length + data.sent.length} messages, ` +
|
||||||
|
`${Object.keys(data.contacts).length} contacts, ${Object.keys(data.servers).length} server keys`);
|
||||||
|
};
|
||||||
|
|
||||||
|
$("import-data-trigger").onclick = () => $("import-data").click();
|
||||||
|
|
||||||
|
$("import-data").onchange = async (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
event.target.value = ""; // same file re-selected twice must still fire change
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(await file.text());
|
||||||
|
const s = await store.importData(data);
|
||||||
|
const conflicts = s.pinsConflicted + s.contactsConflicted;
|
||||||
|
status([
|
||||||
|
`imported ${s.mailAdded} message(s), ${s.pinsAdded} server key(s), ${s.contactsAdded} contact(s)`,
|
||||||
|
conflicts ? `${conflicts} entr${conflicts === 1 ? "y" : "ies"} already known under a ` +
|
||||||
|
"different key — kept what was already here, skipped the import's version" : null,
|
||||||
|
s.malformed ? `${s.malformed} malformed entr${s.malformed === 1 ? "y" : "ies"} skipped` : null,
|
||||||
|
].filter(Boolean).join("\n"), Boolean(conflicts || s.malformed));
|
||||||
|
renderSettings();
|
||||||
|
renderFolder();
|
||||||
|
} catch (err) {
|
||||||
|
status(`import failed: ${err.message}`, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
async function rotateIdentity() {
|
async function rotateIdentity() {
|
||||||
const me = store.identity();
|
const me = store.identity();
|
||||||
const addr = accountAddress();
|
const addr = accountAddress();
|
||||||
|
|
@ -942,6 +1007,17 @@ async function rotateIdentity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unlike rotate, nothing is kept: this is the seed's only local copy, gone.
|
||||||
|
async function logout() {
|
||||||
|
if (!window.confirm(
|
||||||
|
"Log out and erase this browser?\n\n" +
|
||||||
|
"This deletes the seed, pins, contacts and every cached message from this browser. Make sure " +
|
||||||
|
"the seed is backed up elsewhere first — without it there is no way back in."
|
||||||
|
)) return;
|
||||||
|
await store.clearAll();
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
// --- boot --------------------------------------------------------------------------
|
// --- boot --------------------------------------------------------------------------
|
||||||
|
|
||||||
// A deployment MAY bake in a server key (the NixOS module's `presetServer`),
|
// A deployment MAY bake in a server key (the NixOS module's `presetServer`),
|
||||||
|
|
|
||||||
105
web/js/store.js
105
web/js/store.js
|
|
@ -234,3 +234,108 @@ export async function unreadCount() {
|
||||||
const read = load().read || {};
|
const read = load().read || {};
|
||||||
return ids.filter(id => !read[id]).length;
|
return ids.filter(id => !read[id]).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- export / import: mail, contacts, pins — never the seed ---------------------
|
||||||
|
|
||||||
|
// btoa/atob work on JS's UTF-16 "binary string" convention (one code unit per
|
||||||
|
// byte); this just bridges that to Uint8Array. Browser-only, like the rest of
|
||||||
|
// this file — no Node test exercises store.js, unlike crypto.js/proto.js.
|
||||||
|
const toBase64 = (bytes) => btoa(String.fromCharCode(...bytes));
|
||||||
|
const fromBase64 = (text) => Uint8Array.from(atob(text), c => c.charCodeAt(0));
|
||||||
|
|
||||||
|
// Deliberately excludes the seed: it already has its own reveal-and-copy flow
|
||||||
|
// in settings, meant for a password manager, not a downloadable file. This
|
||||||
|
// covers what fetching makes irreplaceable (SPEC.md §5 deletes mail from the
|
||||||
|
// server once acknowledged) plus the trust state that took real verification
|
||||||
|
// (RESOLVE, key comparison) to build up — pins and contacts.
|
||||||
|
export async function exportData() {
|
||||||
|
const [inbox, sent] = await Promise.all([listMessages("inbox"), listMessages("sent")]);
|
||||||
|
return {
|
||||||
|
gsmolExport: 1,
|
||||||
|
exportedAt: Date.now(),
|
||||||
|
servers: Object.fromEntries(allPins().map(({ host, key }) => [host, key])),
|
||||||
|
contacts: Object.fromEntries(allContacts().map(({ address, key, verified, history }) =>
|
||||||
|
[address, { key, verified, history }])),
|
||||||
|
inbox: inbox.map(row => ({ id: row.id, receivedAt: row.receivedAt, envelope: toBase64(row.envelope) })),
|
||||||
|
sent: sent.map(row =>
|
||||||
|
({ id: row.id, recipient: row.recipient, sentAt: row.sentAt, envelope: toBase64(row.envelope) })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never overwrites a trust binding that already differs locally — the same
|
||||||
|
// rule refreshContact()/saveReplyAddress() apply elsewhere: an existing pin
|
||||||
|
// or contact key changes only by explicit user action, never silently. A
|
||||||
|
// malformed entry (hand-edited file, corruption) is skipped, not fatal — one
|
||||||
|
// bad record cannot abort the rest of the import, matching describe()'s
|
||||||
|
// per-message fail-open elsewhere in the app.
|
||||||
|
// A plain object, as JSON.parse would produce for `{...}`; Object.entries()
|
||||||
|
// on a string iterates its characters rather than failing, which is exactly
|
||||||
|
// the kind of malformed input this rejects as one unit instead of one per char.
|
||||||
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
export async function importData(data) {
|
||||||
|
if (!data || data.gsmolExport !== 1) throw new Error("not a gsmol export file");
|
||||||
|
const summary = { pinsAdded: 0, pinsConflicted: 0, contactsAdded: 0, contactsConflicted: 0,
|
||||||
|
mailAdded: 0, malformed: 0 };
|
||||||
|
|
||||||
|
update(state => {
|
||||||
|
const servers = { ...(state.servers || {}) };
|
||||||
|
if (data.servers !== undefined && !isRecord(data.servers)) summary.malformed++;
|
||||||
|
for (const [host, key] of Object.entries(isRecord(data.servers) ? data.servers : {})) {
|
||||||
|
try {
|
||||||
|
if (b32decode(key).length !== 32) throw new Error("bad length");
|
||||||
|
} catch { summary.malformed++; continue; }
|
||||||
|
if (!(host in servers)) { servers[host] = key; summary.pinsAdded++; }
|
||||||
|
else if (servers[host] !== key) summary.pinsConflicted++;
|
||||||
|
}
|
||||||
|
state.servers = servers;
|
||||||
|
|
||||||
|
const contacts = { ...(state.contacts || {}) };
|
||||||
|
if (data.contacts !== undefined && !isRecord(data.contacts)) summary.malformed++;
|
||||||
|
for (const [address, c] of Object.entries(isRecord(data.contacts) ? data.contacts : {})) {
|
||||||
|
try {
|
||||||
|
if (typeof c.key !== "string" || b32decode(c.key).length !== 32) throw new Error("bad key");
|
||||||
|
} catch { summary.malformed++; continue; }
|
||||||
|
if (!(address in contacts)) {
|
||||||
|
contacts[address] = { key: c.key, verified: Boolean(c.verified), seenAt: Date.now(),
|
||||||
|
...(Array.isArray(c.history) && c.history.length && { history: c.history }) };
|
||||||
|
summary.contactsAdded++;
|
||||||
|
} else if (contacts[address].key !== c.key) {
|
||||||
|
summary.contactsConflicted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.contacts = contacts;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [box, rows] of [["inbox", data.inbox], ["sent", data.sent]]) {
|
||||||
|
if (rows !== undefined && !Array.isArray(rows)) summary.malformed++;
|
||||||
|
for (const row of Array.isArray(rows) ? rows : []) {
|
||||||
|
try {
|
||||||
|
const record = box === "inbox"
|
||||||
|
? { id: row.id, receivedAt: row.receivedAt, envelope: fromBase64(row.envelope) }
|
||||||
|
: { id: row.id, recipient: row.recipient, sentAt: row.sentAt, envelope: fromBase64(row.envelope) };
|
||||||
|
if (await storeIfNew(box, record)) summary.mailAdded++;
|
||||||
|
} catch { summary.malformed++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout: erases the seed, pins, contacts and every cached message from this
|
||||||
|
// browser. Closes the held connection first so the delete isn't left
|
||||||
|
// "blocked" waiting for a handle that never closes on its own.
|
||||||
|
export async function clearAll() {
|
||||||
|
localStorage.removeItem(KEY);
|
||||||
|
cached = derived = null;
|
||||||
|
if (dbPromise) {
|
||||||
|
(await dbPromise).close();
|
||||||
|
dbPromise = null;
|
||||||
|
}
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.deleteDatabase(DB_NAME);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onblocked = () => resolve(); // still completes once the reload closes every handle
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -349,6 +349,9 @@ dialog::backdrop { background: var(--backdrop); }
|
||||||
.filled { background: var(--accent); color: var(--on-accent); font-weight: 500; padding: 8px 20px; }
|
.filled { background: var(--accent); color: var(--on-accent); font-weight: 500; padding: 8px 20px; }
|
||||||
.filled:hover { background: var(--accent); box-shadow: var(--shadow); }
|
.filled:hover { background: var(--accent); box-shadow: var(--shadow); }
|
||||||
.filled:disabled { background: var(--hover); color: var(--muted); box-shadow: none; }
|
.filled:disabled { background: var(--hover); color: var(--muted); box-shadow: none; }
|
||||||
|
/* Irreversible, not just consequential — rotate stays plain-text, this doesn't. */
|
||||||
|
.danger { background: var(--warn); color: var(--on-warn); font-weight: 500; padding: 8px 20px; }
|
||||||
|
.danger:hover { background: var(--warn); box-shadow: var(--shadow); }
|
||||||
.error { color: var(--warn); font-size: 13px; }
|
.error { color: var(--warn); font-size: 13px; }
|
||||||
|
|
||||||
#settings section { margin-bottom: 26px; }
|
#settings section { margin-bottom: 26px; }
|
||||||
|
|
@ -407,6 +410,7 @@ dialog::backdrop { background: var(--backdrop); }
|
||||||
color: var(--warn);
|
color: var(--warn);
|
||||||
}
|
}
|
||||||
.seed-card .row { display: flex; align-items: center; gap: 8px; }
|
.seed-card .row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.backup-row { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
|
||||||
.seed-card .seed { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px; word-break: break-all; }
|
.seed-card .seed { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px; word-break: break-all; }
|
||||||
.seed-card p { margin: 10px 0 0; font-size: 12px; color: var(--muted); }
|
.seed-card p { margin: 10px 0 0; font-size: 12px; color: var(--muted); }
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue