diff --git a/AGENTS.md b/AGENTS.md index 89d4f4d..68adbfc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ src/chelonia/lists.js creating a list, inviting, joining src/chelonia/lists-model.js the lists schema and its one reducer, both pure src/chelonia/todos.js the todos slot and the six writes src/chelonia/todos-model.js the schema and the reducers, both pure +src/chelonia/offline.js the queue for writes made while the server is away src/components/ Vue, and nothing else scripts/build-contracts.mjs chel manifest -> chel pin -> manifest CID scripts/chel.mjs runs chel from node_modules, see below @@ -45,9 +46,17 @@ database backend, and a `server_id` the server refuses to start without). `chel init` generates it with the in-memory backend, which loses every account on restart, so the script switches it to sqlite under `data/`. +Ctrl+C stops `npm run serve` and everything under it. In a script, killing only +the `scripts/chel.mjs` process by name leaves the server it spawned running, so +signal the process group or use the port: `lsof -ti:8000 | xargs kill`. + After a full rebuild, restart `npm run serve`. Vite empties `dist/` and a server that was already running answers 404 until it is restarted. +The app is built with `LIGHTWEIGHT_CLIENT=true` (see `vite.config.js`), the +same as Group Income: the browser keeps no message log, and Chelonia reads each +contract's HEAD from the saved state. + The contract version comes from `version` in `package.json`. Editing a contract without bumping it makes the build stop, since the app would then be built against a manifest the accounts already on the server do not have. @@ -57,6 +66,10 @@ against a manifest the accounts already on the server do not have. Each one is fenced in the source with `TODO: BEGIN REMOVEME (issue)` and `TODO: END REMOVEME (issue)`, so `grep REMOVEME` finds them all. +Several of these are already fixed upstream but not published. The app pins +`@chelonia/lib` 1.5.0 and `@chelonia/cli` 3.4.0, so a merged fix changes nothing +here until there is a release to bump to. + - `scripts/chel.mjs` and `.github/workflows/ci.yml`: the published `@chelonia/cli` 3.4.0 cannot load SQLite on its own, so chel is run with `DENO_SQLITE_PATH` pointing at the system library. Fixed by @@ -69,13 +82,23 @@ Each one is fenced in the source with `TODO: BEGIN REMOVEME (issue)` and contract created without an account to bill it to under that exact name. [chel#160](https://github.com/okTurtles/chel/issues/160). - `src/chelonia/auth.js`, `lookupUsername`: replaced by - `chelonia/out/nameToContractID` once a `@chelonia/lib` release has + `chelonia/out/nameToContractID`. Merged as + [libcheloniajs#95](https://github.com/okTurtles/libcheloniajs/pull/95), + tracked as [libcheloniajs#90](https://github.com/okTurtles/libcheloniajs/issues/90). - `src/chelonia/auth.js`, signup error message: the publish error carries the - HTTP status once a release has the fix for + HTTP status, so signup can say why it failed. Merged as + [libcheloniajs#97](https://github.com/okTurtles/libcheloniajs/pull/97), + tracked as [libcheloniajs#94](https://github.com/okTurtles/libcheloniajs/issues/94). - `src/chelonia/auth.js`, `USERNAME_REGEX`: a copy of chel's private `NAME_REGEX`. Goes once chel exports the rule. +- `src/chelonia/offline.js`, `ensureRandomUUID`: `@chelonia/lib` builds + persistent action ids with `crypto.randomUUID`, which browsers only provide + on https and localhost, so the demo breaks over the LAN. Merged as + [libcheloniajs#101](https://github.com/okTurtles/libcheloniajs/pull/101), + tracked as + [libcheloniajs#100](https://github.com/okTurtles/libcheloniajs/issues/100). - `src/chelonia/auth.js`, the key list in `signup`: gets shorter once [libcheloniajs#91](https://github.com/okTurtles/libcheloniajs/issues/91) lands. Not a removal, so it is a plain TODO. diff --git a/README.md b/README.md index 3ad8eeb..030b0d8 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,12 @@ scripts, and the files the first run creates, are listed in of the contract. 7. Share a list: press **Share**, open the link in a private window, sign up there and join. Keep the first window open, it is the one that answers. -8. Look at what the server actually has: +8. Change your password from the **account** link at the bottom, then log out + and in with the new one. The same panel deletes the account, along with + the lists it created. +9. Stop the server with Ctrl-C and keep adding todos. They show up straight + away and wait; start the server again and they go through. +10. Look at what the server actually has: ```bash chel eventsAfter 0 diff --git a/docs/data.md b/docs/data.md index eed41b2..9862f33 100644 --- a/docs/data.md +++ b/docs/data.md @@ -46,3 +46,37 @@ declared slot at `rootState._kv[contractID][key]` and updates it from four places: the first load, a push from another client, our own write, and a refetch after the socket reconnects. That state object is a Vue `reactive()`, so a `computed` over it reruns on all four and the list redraws by itself. + +## Offline + +`chelonia/kv/update` needs the server, so while the socket is down a write goes +into Chelonia's persistent action queue instead (`src/chelonia/offline.js`). +The queue stores `[selector, ...args]` as JSON, which is why writes are named +(`'addTodo'`, `'setTitle'`, ...) and the reducer is looked up when the write +runs. The queue lives under one localStorage key, so it survives a reload, and +`retryAll` is called as soon as the socket is back. + +Until a write lands, `currentTodos` applies it on top of the mirror value, so +the list looks the same offline as it will once the server has it. When a write +succeeds it is taken off that overlay, on `PERSISTENT_ACTION_SUCCESS`. When the +server refuses a write it is dropped and the list says so, because retrying +would only get the same refusal. + +One case does not hold. A todo made while the server is away cannot be ticked +off, renamed or deleted until it has landed: the change is applied to the value +the server has, which does not have that todo in it, so it does nothing and is +lost. `test/e2e/offline.spec.mjs` has it, marked as a known gap. + +Two things worth knowing about the queue: + +- The queue is stored once for the whole browser, but each window keeps its + own copy and saves all of it at once, so two windows writing offline + overwrite each other. A window closed before its writes have been sent + loses them. +- Logging out cancels every queued write, and they are never sent. It has to: + the keys that would sign them are discarded with the session. The app warns + you and asks whether to log out anyway. + +Only individual todo items use the offline queue. Operations on a whole list, +creating one, renaming it, or sharing it, all need the server to be online, so +those controls stay disabled until a connection is back. diff --git a/docs/login.md b/docs/login.md index 83260aa..bab5764 100644 --- a/docs/login.md +++ b/docs/login.md @@ -3,44 +3,129 @@ This is for someone reading `src/chelonia/auth.js`. The README explains what happens without any of this; start there if you have not. -Two more key names appear here, next to the three from -[sharing.md](sharing.md). Both are derived from the password and neither is -ever stored: +## The two salts + +The password never leaves the browser. What the server keeps instead are two +salts and a hash of the password, which is not the same as keeping nothing: +whoever holds that hash can guess passwords against it offline, so the salts and +the cost of the hash are what stand between a weak password and the account. +Which salt does what is worth knowing before reading the steps: + +| name | kept by | used for | +| --- | --- | --- | +| authentication salt | the server, handed out on request | turning the password into the hash the server checks a login against | +| contract salt | the server, handed back only after a successful login | deriving the account's own keys | + +The authentication salt is public in practice, since the server gives it to +anyone who asks for an account by name. The contract salt is not: the server +only releases it to someone who has just shown they know the password. So a +stolen hash lets someone guess at the password, but it does not on its own put +the account's keys in reach, because those need the contract salt too. + +The full scheme, including why it is built this way, is in okTurtles's +[password salting](https://gitlab.okturtles.org/okturtles/group-income-simple/-/wikis/E2E-Protocol/Password-salting.md) +notes. What follows is only what this app does with it. + +## The keys + +Two keys come from the password and the contract salt. Neither is ever stored, +because both can be worked out again from the password: | name | what it does | | --- | --- | | `ipk` | signs the message that creates the account | -| `iek` | encrypts the account's other secrets inside the contract, so logging in on a new machine can open them | +| `iek` | encrypts the account's other secret keys, so they can travel inside the contract | + +Three more are generated at random and stay with the account for its life. They +are the ones described in [sharing.md](sharing.md): `csk` signs and `cek` +encrypts. `#sak` is how the server knows which account is behind a request, so +it can be billed and counted against that account, and serving +`/kv/:contractID/:key` is one of the things it is checked for. Their secret +halves are stored inside the contract itself, each one encrypted with the +`iek`. That is what makes logging in on a machine that has never seen the +account possible: work out the `iek` again from the password, and it opens the +other three. ## Signup -1. Register a salt against `/zkpp/register/:username`. The server gets a blinded - hash, never the password, and returns a salt plus a one-time token. -2. Derive `ipk` and `iek` from the password and that salt. -3. Generate the everyday keys, `csk`, `cek` and `#sak`. Their secret halves go - into the contract encrypted to the `iek`. -4. `chelonia/out/registerContract`, signed by the `ipk`, with the username in - the `shelter-namespace-registration` header and the token in - `shelter-salt-registration-token`. -5. Keep `csk`, `cek` and `#sak`. Discard `ipk` and `iek`. +1. `POST /zkpp/register/:username` twice. The first call commits to a one-time + public key and gets the server's half back; the second sends the hash of the + password under the new authentication salt. The server ends up storing that + hash and both salts, and never sees the password. +2. Work out `ipk` and `iek` from the password and the contract salt. +3. Generate `csk`, `cek` and `#sak`, and encrypt each one's secret half with + the `iek`. +4. `chelonia/out/registerContract`, signed by the `ipk`. The username goes in + the `shelter-namespace-registration` header and the one-time token from step + 1 in `shelter-salt-registration-token`, which is what makes the server + accept a contract with no account to bill it to. +5. Keep the secret halves of `csk`, `cek` and `#sak`, which is what Chelonia + needs to sign and read from here on. Throw away the secret halves of `ipk` + and `iek`: their public halves stay in the contract, and the secrets are + worked out from the password again when they are needed. Nothing derived + from the password is left in the browser after this. 6. Create the account's first list. See [sharing.md](sharing.md). ## Login 1. `GET /name/:username` gives the contract ID. -2. Prove the password against `/zkpp/:contractID/auth_hash` and - `/contract_hash`, which returns the same salt as at signup. -3. Derive the `iek` and hand it to Chelonia as a transient key. +2. Show the server that we know the password, without sending it. `GET + /zkpp/:contractID/auth_hash` hands back the authentication salt, the browser + hashes the password with it and derives a value the server can check but + cannot reverse, and `GET /zkpp/:contractID/contract_hash` sends that value + in. If it checks out, the server returns the contract salt, encrypted with a + key that only someone who completed this exchange can work out. +3. Work out the `iek` from the password and that salt, and hand it to Chelonia + as a transient key. 4. `chelonia/contract/retain`. Syncing the contract decrypts `csk`, `cek` and `#sak` with the `iek` and stores them. That is the whole recovery. -5. Discard the `iek`. +5. Throw the `iek` away. 6. Load the `lists` slot and open each list in it. -A wrong password comes back from the server as a 500, not as a clean failure, so -the app tells a bad password apart from a connection problem by whether the -server answered at all. +A wrong password comes back from the server as a 500 rather than a clean +failure, so the app cannot tell the two apart by the status. It goes by whether +the server answered at all: an answer of any kind means the password was +rejected, and no answer means the server could not be reached. ## Reload -None of the above. The secrets and the contract state are already in the saved +None of the above. The keys and the contract state are already in the saved blob, so it only re-syncs. + +## Changing the password + +1. Show the server that we know the current password, the same exchange as + login step 2. +2. `POST /zkpp/:contractID/updatePasswordHash` with that proof and the new + password's hash, encrypted with a key derived from the same exchange + (`buildUpdateSaltRequestEc`). The server replaces the stored salts and hash, + and answers with the old contract salt and a one-time token. +3. Work out the old `ipk` and `iek` from the old password and old salt, and the + new pair from the new password and new salt. +4. `chelonia/out/keyUpdate`, signed by the old `ipk`, with the token in the + `shelter-salt-update-token` header. Only `ipk` and `iek` are replaced. + `csk`, `cek` and `#sak` keep the same keys and only have their stored + secrets re-encrypted with the new `iek`, so nothing already written to the + contract has to change. +5. Write the deletion token again, encrypted with the new `iek`. +6. Throw away all four password-derived keys. + +## Deleting the account + +At signup the app generates a random deletion token, sends only its hash in +`shelter-deletion-token-digest`, and keeps the token itself inside the contract +encrypted with the `iek`. So the token can only be recovered by someone who +knows the password. + +1. Show the server that we know the password and work out the `iek`, as at + login. +2. Decrypt the token out of `attributes.encryptedDeletionToken`. +3. `chelonia/out/deleteContract` with the token. The server answers 202 and + deletes the contract in the background, along with every list this account + created. Lists that were only joined through an invite belong to whoever + created them, and are left alone. +4. Log out locally. + +The username stays taken. In chel 3.4.0 the name keeps pointing at the deleted +contract and is reported as orphaned, and there is nothing that later frees it, +so signing up again with the same name is refused. diff --git a/docs/sharing.md b/docs/sharing.md index 8e0850d..fe63fbe 100644 --- a/docs/sharing.md +++ b/docs/sharing.md @@ -33,7 +33,7 @@ their own number is what makes handing the list over a single step: whoever receives the `cek` can then read the other two out of the contract. The same three secrets also go to the creator's *own* identity contract, with -`chelonia/out/keyShare`, encrypted to that identity's `cek`. Without this step +`chelonia/out/keyShare`, encrypted with that identity's `cek`. Without this step they would exist only in the browser that made the list, and logging out would lose it. Logging in replays the identity log and they come back. diff --git a/eslint.config.mjs b/eslint.config.mjs index d36700c..f818ae6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -32,8 +32,9 @@ export default [ } }, { - // Contracts run in Chelonia's sandbox, which provides `sbp` as a global. + // Contracts run in Chelonia's sandbox, which provides `sbp` and a + // `require` limited to the modules the app passes in. files: ['src/contracts/*.js'], - languageOptions: { globals: { sbp: 'readonly' } } + languageOptions: { globals: { sbp: 'readonly', require: 'readonly' } } } ] diff --git a/package.json b/package.json index 4ed6a14..07bafe2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chelonia-todomvc", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "engines": { diff --git a/src/chelonia/auth.js b/src/chelonia/auth.js index aeb7c49..d4f01ba 100644 --- a/src/chelonia/auth.js +++ b/src/chelonia/auth.js @@ -1,18 +1,20 @@ // Signup, login, logout and session restore. // // IPK and IEK are derived from the password and never stored. CSK, CEK and SAK -// are random, and their secret halves sit in the contract encrypted to the IEK. -// That is what makes login work on a machine that has never seen the account: -// deriving the IEK is enough for Chelonia to open them while it syncs. +// are random, and their secret halves sit inside the contract, each encrypted +// with the IEK. That is what makes login work on a machine that has never seen +// the account: deriving the IEK again is enough for Chelonia to open them while +// it syncs. import sbp from '@sbp/sbp' import { Secret } from '@chelonia/lib/Secret' -import { encryptedOutgoingDataWithRawKey } from '@chelonia/lib/encryptedData' -import { bytesToB64 } from '@chelonia/lib/functions' +import { encryptedIncomingData, encryptedOutgoingDataWithRawKey } from '@chelonia/lib/encryptedData' +import { blake32Hash, bytesToB64 } from '@chelonia/lib/functions' import { base64ToBase64url, boxKeyPair, buildRegisterSaltRequest, + buildUpdateSaltRequestEc, computeCAndHc, decryptContractSalt, hash, @@ -23,25 +25,33 @@ import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, deriveKeyFromPassword, + generateSalt, keyId, keygen, serializeKey } from '@chelonia/crypto' import { API_URL, CONTRACT_NAME } from './config.js' -import { createList, loadLists, retainOrSync } from './lists.js' +import { AuthError } from './errors.js' +import { + createList, currentLists, keyIdByName, loadLists, requireIdentity, retainOrSync +} from './lists.js' +import { dropPendingWrites, loadOfflineQueue } from './offline.js' import { clearSavedState, persistState, state } from './state.js' const DEFAULT_LIST_TITLE = 'My todos' -export class AuthError extends Error { - constructor (message, options) { - super(message, options) - this.name = 'AuthError' - // Login turns most failures into "incorrect username or password". This - // marks the ones whose message is already the right one. - this.exact = !!options?.exact - } -} +// A failure here is nearly always the password. An AuthError that already says +// something exact is passed through as it is. +const wrongPassword = (e) => + e instanceof AuthError && e.exact ? e : new AuthError('Incorrect password.', { cause: e }) + +// Opens the deletion token kept in the contract. Both password paths need it, +// and both have to name the same additionalData string. +const openDeletionToken = (identityContractID, identityState, encryptedToken, IEK) => + encryptedIncomingData( + identityContractID, identityState, encryptedToken, NaN, + { [keyId(IEK)]: IEK }, 'encryptedDeletionToken' + ).valueOf() // TODO: BEGIN REMOVEME (copy of chel's private NAME_REGEX, until chel exports it) // Copied from NAME_REGEX in chel's src/serve/routes.ts. The server rejects @@ -111,19 +121,27 @@ async function registerSalt (username, password) { return [contractSalt, decryptContractSalt(encryptionKey, encryptedToken)] } -// The other half: prove the password for an existing account and get the same -// salt back. The second element is the CID anchoring previously rotated keys, -// which only matters once an app supports password changes. -async function retrieveSalt (identityContractID, password) { - const r = randomNonce() - const contract = encodeURIComponent(identityContractID) - +// Shows the server we know the password, without sending it. Login, changing +// the password and deleting the account all begin with this. `c` is the value +// both sides end up with, and the server encrypts its answer with a key +// derived from it, so only someone who finished this exchange can read it. +async function provePassword (identityContractID, password) { + const nonce = randomNonce() const { authSalt, s, sig } = await request( - `/zkpp/${contract}/auth_hash?b=${encodeURIComponent(hash(r))}` - ).then((r) => r.json()) + `/zkpp/${encodeURIComponent(identityContractID)}/auth_hash` + + `?b=${encodeURIComponent(hash(nonce))}` + ).then((response) => response.json()) - const [c, hc] = computeCAndHc(r, s, await hashPassword(password, authSalt)) - const query = new URLSearchParams({ r, s, sig, hc: toBase64url(hc) }) + const [c, hc] = computeCAndHc(nonce, s, await hashPassword(password, authSalt)) + return { r: nonce, s, sig, c, hc: toBase64url(hc) } +} + +// The second half of the password proof: get the contract salt back for an +// existing account, encrypted so that only a completed exchange can read it. +async function retrieveSalt (identityContractID, password) { + const contract = encodeURIComponent(identityContractID) + const { c, ...proof } = await provePassword(identityContractID, password) + const query = new URLSearchParams(proof) const encryptedSalt = await request(`/zkpp/${contract}/contract_hash?${query}`) .then((r) => r.text()) @@ -149,11 +167,16 @@ export async function signup ({ username, password }) { // Re-derivable at login, so never stored. const IPK = await deriveKeyFromPassword(EDWARDS25519SHA512BATCH, password, contractSalt) const IEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, contractSalt) - // Slot writes are signed with the CSK and encrypted to the CEK. The SAK signs - // the Shelter authorization header; without it every /kv request fails. + // Slot writes are signed with the CSK and encrypted with the CEK. The SAK + // signs the Shelter authorization header; without it every /kv request + // fails. const CSK = keygen(EDWARDS25519SHA512BATCH) const CEK = keygen(CURVE25519XSALSA20POLY1305) const SAK = keygen(EDWARDS25519SHA512BATCH) + // Lets the account delete itself later. The server keeps only the hash, and + // the token itself sits in the contract encrypted with the IEK, so deleting + // takes the password. + const deletionToken = generateSalt() // Transient, so neither of the password-derived keys reaches the saved state. sbp('chelonia/storeSecretKeys', new Secret([ @@ -170,7 +193,8 @@ export async function signup ({ username, password }) { // this first message. headers: { 'shelter-namespace-registration': username, - 'shelter-salt-registration-token': saltRegistrationToken + 'shelter-salt-registration-token': saltRegistrationToken, + 'shelter-deletion-token-digest': blake32Hash(deletionToken) } }, signingKeyId: keyId(IPK), @@ -231,7 +255,13 @@ export async function signup ({ username, password }) { data: serializeKey(SAK, false) } ], - data: { attributes: { username } } + data: { + attributes: { + username, + encryptedDeletionToken: encryptedOutgoingDataWithRawKey(IEK, deletionToken) + .serialize('encryptedDeletionToken') + } + } }) } catch (e) { // TODO: BEGIN REMOVEME (okTurtles/libcheloniajs#94) @@ -279,6 +309,12 @@ export async function login ({ username, password }) { // Syncing is the recovery step: processing OP_CONTRACT decrypts the CSK, // CEK and SAK with the IEK and stores them persistently. await sbp('chelonia/contract/retain', [identityContractID]) + // After a password change those three are only readable from the key + // update onwards, so the first pass could not open anything before it. + // Go through the log once more now that they are known. + if (state.contracts[identityContractID]?.missingDecryptionKeyIds?.length) { + await sbp('chelonia/contract/sync', [identityContractID], { resync: true }) + } } finally { sbp('chelonia/clearTransientSecretKeys', [keyId(IEK)]) } @@ -293,7 +329,7 @@ export async function restoreSession () { await retainOrSync(identityContractID) sbp('chelonia/kv/refreshFilters') - await loadLists(identityContractID) + await loadListsAndQueue(identityContractID) return identityContractID } @@ -302,7 +338,137 @@ async function enterSession (identityContractID) { // The slot's `match` reads loggedIn, which Chelonia cannot watch. sbp('chelonia/kv/refreshFilters') await sbp('chelonia/contract/wait', [identityContractID]) + await loadListsAndQueue(identityContractID) +} + +// Queued writes for a list this account is not in belong to whoever used this +// browser before, so the lists have to be known first. +async function loadListsAndQueue (identityContractID) { await loadLists(identityContractID) + await loadOfflineQueue((contractID) => currentLists().includes(contractID)) +} + +export async function changePassword ({ oldPassword, newPassword }) { + const identityContractID = requireIdentity() + const identityState = state[identityContractID] + const contract = encodeURIComponent(identityContractID) + + // Starts with the same exchange as login. The new password's hash travels + // encrypted with a key derived from that exchange, and the answer is the old + // contract salt plus a one-time token, which is what lets the next message + // swap the salts on the server. + let oldContractSalt, newContractSalt, updateToken + try { + const { c, ...proof } = await provePassword(identityContractID, oldPassword) + const [salt, Ea] = await buildUpdateSaltRequestEc(newPassword, c) + newContractSalt = salt + const encrypted = await request( + `/zkpp/${contract}/updatePasswordHash`, form({ ...proof, Ea }) + ).then((response) => response.json()) + ;[oldContractSalt, updateToken] = JSON.parse(decryptContractSalt(c, encrypted)) + } catch (e) { + throw wrongPassword(e) + } + + const oldIPK = await deriveKeyFromPassword(EDWARDS25519SHA512BATCH, oldPassword, oldContractSalt) + const oldIEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, oldPassword, oldContractSalt) + const IPK = await deriveKeyFromPassword(EDWARDS25519SHA512BATCH, newPassword, newContractSalt) + const IEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, newPassword, newContractSalt) + + // Read while the old IEK is still the current key. + const encryptedToken = identityState.attributes?.encryptedDeletionToken + const deletionToken = encryptedToken && + openDeletionToken(identityContractID, identityState, encryptedToken, oldIEK) + + sbp('chelonia/storeSecretKeys', new Secret( + [oldIPK, oldIEK, IPK, IEK].map((key) => ({ key, transient: true })) + )) + try { + // Only the two password-derived keys, IPK and IEK, are replaced. CSK, CEK + // and SAK keep the same keys and only have their stored secrets + // re-encrypted with the new IEK, so nothing already written to the + // contract has to change. Each entry goes back in with its own id and + // public half, because Chelonia matches the decrypted secret to the id. + const keep = (name) => { + const id = keyIdByName(identityState, name) + return { + id, + name, + oldKeyId: id, + data: identityState._vm.authorizedKeys[id].data, + meta: { private: { content: encryptedOutgoingDataWithRawKey(IEK, state.secretKeys[id]) } } + } + } + await sbp('chelonia/out/keyUpdate', { + contractID: identityContractID, + contractName: CONTRACT_NAME, + data: [ + { + id: keyId(IPK), + name: 'ipk', + oldKeyId: keyId(oldIPK), + meta: { private: { transient: true } }, + data: serializeKey(IPK, false) + }, + { + id: keyId(IEK), + name: 'iek', + oldKeyId: keyId(oldIEK), + meta: { private: { transient: true } }, + data: serializeKey(IEK, false) + }, + keep('csk'), + keep('cek'), + keep('#sak') + ], + signingKeyId: keyId(oldIPK), + // The server swaps the salts while it accepts this message. + publishOptions: { headers: { 'shelter-salt-update-token': updateToken } } + }) + if (deletionToken) { + await sbp('chelonia/out/actionEncrypted', { + action: `${CONTRACT_NAME}/setDeletionToken`, + contractID: identityContractID, + data: { + encryptedDeletionToken: encryptedOutgoingDataWithRawKey(IEK, deletionToken) + .serialize('encryptedDeletionToken') + }, + signingKeyId: keyIdByName(identityState, 'csk'), + encryptionKeyId: keyIdByName(identityState, 'cek') + }) + } + await sbp('chelonia/contract/wait', [identityContractID]) + } finally { + sbp('chelonia/clearTransientSecretKeys', [oldIPK, oldIEK, IPK, IEK].map(keyId)) + } +} + +export async function deleteAccount ({ password }) { + const identityContractID = requireIdentity() + const identityState = state[identityContractID] + const encryptedToken = identityState?.attributes?.encryptedDeletionToken + if (!encryptedToken) { + throw new AuthError('This account has no deletion token.') + } + + let token + try { + const contractSalt = await retrieveSalt(identityContractID, password) + const IEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, contractSalt) + token = openDeletionToken(identityContractID, identityState, encryptedToken, IEK) + } catch (e) { + throw wrongPassword(e) + } + + // The server takes it from here and also deletes the lists this account + // created. Lists it only joined belong to whoever made them. + const [result] = await sbp('chelonia/out/deleteContract', identityContractID, { + [identityContractID]: { token: new Secret(token) } + }) + if (result.status === 'rejected') { + throw new AuthError('Could not delete the account.', { cause: result.reason }) + } + await logout() } // Read from the contract state rather than kept alongside the session, so @@ -313,6 +479,9 @@ export function currentUsername () { } export async function logout () { + // Unsent writes cannot go out without this account's keys. + await dropPendingWrites() + sbp('chelonia.persistentActions/unload') // Stop saving before reset churns through the state, then start again for // whoever logs in next. clearSavedState() diff --git a/src/chelonia/config.js b/src/chelonia/config.js index a99dd9e..e4c2ac3 100644 --- a/src/chelonia/config.js +++ b/src/chelonia/config.js @@ -1,5 +1,6 @@ import sbp from '@sbp/sbp' import '@chelonia/lib' +import { isRawEncryptedData } from '@chelonia/lib/encryptedData' import manifestsFile from '../contracts/manifests.json' import './state.js' @@ -31,7 +32,11 @@ export async function configureChelonia () { // The contract calls no selectors, so nothing needs allowing through. allowedSelectors: [], allowedDomains: [], - preferSlim: false + preferSlim: false, + // What the contract is allowed to `require`. Chelonia gives the + // sandbox a `require` that resolves only what is listed here, which is + // how a contract shares code without bundling any. + modules: { '@chelonia/lib/encryptedData': { isRawEncryptedData } } } } }) diff --git a/src/chelonia/connection.js b/src/chelonia/connection.js index 05bd68a..476d8e5 100644 --- a/src/chelonia/connection.js +++ b/src/chelonia/connection.js @@ -6,10 +6,11 @@ import { PUBSUB_RECONNECTION_SCHEDULED, PUBSUB_RECONNECTION_SUCCEEDED } from '@chelonia/lib/pubsub' +import { retryPendingWrites } from './offline.js' // The socket is the only thing that says the server went away mid-session. -// Reads keep working off the mirror, so without this the app looks fine while -// every write fails. +// Reads keep working off the mirror, so without this the app would not know +// to queue writes instead of sending them. export const connection = reactive({ online: true }) export function watchConnection () { @@ -23,5 +24,6 @@ export function watchConnection () { // Also fires on the first open, not just on a reconnect. sbp('okTurtles.events/on', PUBSUB_RECONNECTION_SUCCEEDED, () => { connection.online = true + retryPendingWrites() }) } diff --git a/src/chelonia/errors.js b/src/chelonia/errors.js new file mode 100644 index 0000000..be2d2ab --- /dev/null +++ b/src/chelonia/errors.js @@ -0,0 +1,11 @@ +// Here rather than in auth.js, so lists.js can throw one without importing the +// module that imports it. +export class AuthError extends Error { + constructor (message, options) { + super(message, options) + this.name = 'AuthError' + // Login turns most failures into "incorrect username or password". This + // marks the ones whose message is already the right one. + this.exact = !!options?.exact + } +} diff --git a/src/chelonia/index.js b/src/chelonia/index.js index 7f7ec2c..ff582e7 100644 --- a/src/chelonia/index.js +++ b/src/chelonia/index.js @@ -3,17 +3,23 @@ import { watchConnection } from './connection.js' import { persistState } from './state.js' import { restoreSession } from './auth.js' import { defineListsSlot } from './lists.js' +import { setupOfflineQueue } from './offline.js' import { defineTodosSlot } from './todos.js' export async function startChelonia () { watchConnection() await configureChelonia() + setupOfflineQueue() defineListsSlot() defineTodosSlot() persistState() return restoreSession() } -export { AuthError, currentUsername, login, logout, signup } from './auth.js' +export { + changePassword, currentUsername, deleteAccount, login, logout, signup +} from './auth.js' +export { AuthError } from './errors.js' +export { pendingWrites } from './offline.js' export { connection } from './connection.js' export { state } from './state.js' diff --git a/src/chelonia/lists.js b/src/chelonia/lists.js index bb528e5..b43a96e 100644 --- a/src/chelonia/lists.js +++ b/src/chelonia/lists.js @@ -21,6 +21,7 @@ import { serializeKey } from '@chelonia/crypto' import { CONTRACT_NAME, LIST_CONTRACT_NAME } from './config.js' +import { AuthError } from './errors.js' import { state } from './state.js' import { addList, listsSchema } from './lists-model.js' @@ -89,13 +90,15 @@ async function openLists (contractIDs = currentLists()) { sbp('chelonia/kv/refreshFilters') } -function requireIdentity () { +// Exported because auth.js needs the same check, and the account screens show +// an AuthError's message as it is. +export function requireIdentity () { const identityContractID = state.loggedIn?.identityContractID - if (!identityContractID) throw new Error('Not logged in') + if (!identityContractID) throw new AuthError('Not logged in.') return identityContractID } -const keyIdByName = (contractIDOrState, name) => +export const keyIdByName = (contractIDOrState, name) => sbp('chelonia/contract/currentKeyIdByName', contractIDOrState, name) export async function createList (title) { @@ -108,8 +111,8 @@ export async function createList (title) { // registerContract signs OP_CONTRACT with a key Chelonia already holds. sbp('chelonia/storeSecretKeys', new Secret([{ key: CSK }, { key: CEK }, { key: SAK }])) - // Everything is encrypted to the list's own CEK, so handing over the CEK - // hands over the rest. + // Every secret here is encrypted with the list's own CEK, so handing over + // the CEK hands over the rest. const secret = (key) => encryptedOutgoingDataWithRawKey(CEK, serializeKey(key, true)) const message = await sbp('chelonia/out/registerContract', { @@ -270,8 +273,8 @@ export async function acceptInvite ({ contractID, secret }) { // Transient: it signs one message and is not ours to keep. sbp('chelonia/storeSecretKeys', new Secret([{ key: inviteKey, transient: true }])) try { - // Syncs the list too, which is where the public key the request is - // encrypted to comes from. + // Syncs the list too, which is where the public key used to encrypt the + // request comes from. await sbp('chelonia/contract/retain', [contractID]) const identityState = state[identityContractID] diff --git a/src/chelonia/offline.js b/src/chelonia/offline.js new file mode 100644 index 0000000..dba4e08 --- /dev/null +++ b/src/chelonia/offline.js @@ -0,0 +1,131 @@ +// Writes made while the server is unreachable. +// +// They go into Chelonia's persistent action queue, which retries them until +// the server takes them, and until then they are shown on top of the last +// value the server sent. The queue keeps `[selector, ...args]` as plain JSON, +// so a write is described by name and its reducer is looked up when it runs. + +import sbp from '@sbp/sbp' +import { + PERSISTENT_ACTION_FAILURE, + PERSISTENT_ACTION_SUCCESS +} from '@chelonia/lib/events' +import { state } from './state.js' + +const QUEUE_KEY = 'todomvc/pending-writes' +const NO_WRITES = Object.freeze([]) + +export const pendingWrites = () => state.pendingWrites ?? NO_WRITES +export const rejectedWriteMessage = () => state.rejectedWrite ?? '' + +// Set when a session opens. `load` retries every stored write before we get a +// chance to filter them, so the failure handler needs this to tell one of ours +// from one left behind by whoever used this browser before. +let isOurWrite = () => false + +export function setupOfflineQueue () { + ensureRandomUUID() + keepQueueInLocalStorage() + sbp('chelonia.persistentActions/configure', { + databaseKey: QUEUE_KEY, + // maxAttempts has to be a real number rather than Infinity. The queue is + // stored as JSON, where Infinity turns into null, and an action read back + // with a null limit is thrown away the first time it fails. + options: { retrySeconds: 15, maxAttempts: Number.MAX_SAFE_INTEGER } + }) + const forget = ({ id }) => { + state.pendingWrites = pendingWrites().filter((w) => w.id !== id) + } + sbp('okTurtles.events/on', PERSISTENT_ACTION_SUCCESS, forget) + sbp('okTurtles.events/on', PERSISTENT_ACTION_FAILURE, ({ id, error }) => { + // fetch rejects with a TypeError when the server never answered, which is + // what the queue is for. Anything else means it answered and will not take + // this write, so retrying forever would only hide it. + if (error instanceof TypeError) return + const action = sbp('chelonia.persistentActions/status').find((a) => a.id === id) + // A write for a list this account is not in belongs to whoever used this + // browser before. Dropped without a word, since it is not ours to report. + if (action && isOurWrite(action.invocation[1])) { + console.error('[todomvc] the server refused a queued write', error) + state.rejectedWrite = 'A change made offline was refused by the server.' + } + sbp('chelonia.persistentActions/cancel', id) + forget({ id }) + }) +} + +// TODO: BEGIN REMOVEME (okTurtles/libcheloniajs#100) +// PersistentAction ids come from crypto.randomUUID, which browsers only +// provide on https and localhost, so the first queued write throws when the +// demo is opened over the LAN. The lib does this itself now, so this goes with +// the next release. +function ensureRandomUUID () { + if (typeof crypto.randomUUID === 'function') return + crypto.randomUUID = () => { + const bytes = crypto.getRandomValues(new Uint8Array(16)) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') + return [hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20)] + .join('-') + } +} +// TODO: END REMOVEME (okTurtles/libcheloniajs#100) + +// chelonia.db is an in-memory map in this app, and the queue has to outlive a +// reload, so this one key goes to localStorage instead. +// +// sessionStorage would fit the rest of the database better, since that is +// thrown away too, and it would give each window its own queue. It loses more +// than it gains though: a change queued while the server was away is sent on +// the next visit with localStorage, and with sessionStorage it is gone as soon +// as the tab closes, without anything being said. +function keepQueueInLocalStorage () { + const get = sbp('sbp/selectors/fn', 'chelonia.db/get') + const set = sbp('sbp/selectors/fn', 'chelonia.db/set') + // Both return a promise, as the originals do and as their callers expect. + sbp('sbp/selectors/overwrite', { + 'chelonia.db/get': async (key) => + key === QUEUE_KEY ? localStorage.getItem(QUEUE_KEY) : get(key), + 'chelonia.db/set': async (key, value) => + key === QUEUE_KEY ? localStorage.setItem(QUEUE_KEY, value) : set(key, value) + }) + sbp('sbp/selectors/lock', ['chelonia.db/get', 'chelonia.db/set']) +} + +// Called once a session is open. Writes for lists this account is not in +// (another account used this browser and never logged out) are dropped. +// The overlay is rebuilt from the queue rather than from the saved state, so +// what is shown cannot drift from what will actually be sent. +export async function loadOfflineQueue (isOurs) { + isOurWrite = isOurs + // Both are saved with the rest of the state. The overlay is rebuilt below, + // and the notice is about a write that is already gone, so neither should + // survive into this session. + state.pendingWrites = [] + delete state.rejectedWrite + await sbp('chelonia.persistentActions/load') + for (const action of sbp('chelonia.persistentActions/status')) { + if (!isOurs(action.invocation[1])) await sbp('chelonia.persistentActions/cancel', action.id) + } + state.pendingWrites = sbp('chelonia.persistentActions/status').map( + ({ id, invocation: [, contractID, op, ...args] }) => ({ id, contractID, op, args }) + ) +} + +export function queueWrite (invocation, write) { + delete state.rejectedWrite + const [id] = sbp('chelonia.persistentActions/enqueue', invocation) + state.pendingWrites = [...pendingWrites(), { id, ...write }] +} + +export const retryPendingWrites = () => sbp('chelonia.persistentActions/retryAll') + +export async function dropPendingWrites () { + isOurWrite = () => false + for (const { id } of sbp('chelonia.persistentActions/status')) { + await sbp('chelonia.persistentActions/cancel', id) + } + state.pendingWrites = [] + delete state.rejectedWrite +} diff --git a/src/chelonia/todos.js b/src/chelonia/todos.js index 2bf9e05..a7b64be 100644 --- a/src/chelonia/todos.js +++ b/src/chelonia/todos.js @@ -1,7 +1,10 @@ import sbp from '@sbp/sbp' import { CHELONIA_KV_VALIDATION_ERROR } from '@chelonia/lib/events' +import { KV_NOOP } from '@chelonia/lib/kv-constants' import { LIST_CONTRACT_NAME } from './config.js' +import { connection } from './connection.js' import { currentLists } from './lists.js' +import { pendingWrites, queueWrite } from './offline.js' import { state } from './state.js' import { addTodo, @@ -16,6 +19,10 @@ import { const TODOS_KEY = 'todos' const NO_TODOS = Object.freeze({}) +// Reducers by name, because a queued write is stored as JSON and cannot carry +// a function. +const REDUCERS = { addTodo, setCompleted, setTitle, removeTodo, setAllCompleted, removeCompleted } + // One declaration covers the first fetch, the pubsub subscription, the local // mirror, schema validation and the conflict retries. export function defineTodosSlot () { @@ -24,14 +31,33 @@ export function defineTodosSlot () { key: TODOS_KEY, defaultValue: {}, schema: todosSchema, - // Every list we are in, but only once we hold its keys: /kv is authorized - // with the contract's own #sak. Nothing re-runs this when they arrive. - // OP_KEY_SHARE resyncs the contract, and that reconciles the slots. + // Attaches to every list this account is in, but only once we hold that + // list's keys. Between accepting an invite and the owner answering it there + // is nothing here we could read or write: /kv/:contractID/:key is + // authorized with the contract's own #sak. + // + // Nothing re-runs this by hand when the keys finally arrive. Chelonia marks + // the contract dirty on OP_KEY_SHARE and resyncs it, and a resync drops and + // re-adds the subscription, which is what reconciles the slots again. match: (contractID, contractState) => currentLists().includes(contractID) && !!sbp('chelonia/contract/currentKeyIdByName', contractState, '#sak', true) }) + sbp('sbp/selectors/register', { + 'todomvc/todos/write': (contractID, op, ...args) => { + // A queued write comes back from JSON, so the name is only as good as + // what was stored. Throwing something other than a TypeError keeps this + // out of the offline queue. + if (!REDUCERS[op]) throw new Error(`Unknown todo write: ${op}`) + return sbp('chelonia/kv/update', { + contractID, + key: TODOS_KEY, + updater: REDUCERS[op](...args) + }) + } + }) + // A value that fails the schema never reaches the app: the mirror keeps the // last good one and the slot goes to 'error'. The UI reads that status; this // is here so the reason is visible while developing. @@ -44,10 +70,23 @@ export function defineTodosSlot () { // Reading `entry.value` is what makes a Vue computed re-run when Chelonia // updates the mirror. The value itself comes from the selector, which // substitutes the declared default. See "Consumer caveats" in docs/kv.md. +// +// Writes still waiting for the server are applied on top, in the order they +// were made, so the list looks the same offline as it will once they land. export function currentTodos (contractID) { const entry = mirrorEntry(contractID) if (!entry) return NO_TODOS - return entry.value ?? sbp('chelonia/kv/read', contractID, TODOS_KEY) + const saved = entry.value ?? sbp('chelonia/kv/read', contractID, TODOS_KEY) + return pendingWrites() + .filter((w) => w.contractID === contractID) + .reduce((todos, w) => { + // Skipped rather than thrown: this runs inside a computed, and one bad + // entry read back from storage would take the whole list down. + const reducer = REDUCERS[w.op] + if (!reducer) return todos + const next = reducer(...w.args)(todos) + return next === KV_NOOP ? todos : next + }, saved) } // 'non-init' | 'loading' | 'loaded' | 'error' @@ -55,13 +94,24 @@ export function todosStatus (contractID) { return mirrorEntry(contractID)?.status ?? 'non-init' } +export const pendingCount = (contractID) => + pendingWrites().filter((w) => w.contractID === contractID).length + const mirrorEntry = (contractID) => contractID && state._kv?.[contractID]?.[TODOS_KEY] -const write = (contractID, updater) => sbp('chelonia/kv/update', { - contractID, - key: TODOS_KEY, - updater -}) +// Goes straight to the server when it is there. Otherwise, or when the request +// fails before an answer, the write is queued and sent later. +async function write (contractID, op, ...args) { + const invocation = ['todomvc/todos/write', contractID, op, ...args] + if (!connection.online) return queueWrite(invocation, { contractID, op, args }) + try { + await sbp(...invocation) + } catch (e) { + // fetch rejects with a TypeError when the server never answered. + if (!(e instanceof TypeError)) throw e + queueWrite(invocation, { contractID, op, args }) + } +} // Not crypto.randomUUID: that needs a secure context, and opening the demo // from another machine on http://192.168.x.x is not one. @@ -69,17 +119,17 @@ const newId = () => Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, '0')).join('') -export const createTodo = (contractID, title) => write(contractID, addTodo({ +export const createTodo = (contractID, title) => write(contractID, 'addTodo', { id: newId(), // Server time, so a tab with a wrong clock sorts the same as everyone else. createdDate: new Date(sbp('chelonia/time')).toISOString(), title -})) +}) export const setTodoCompleted = (contractID, id, completed) => - write(contractID, setCompleted(id, completed)) -export const renameTodo = (contractID, id, title) => write(contractID, setTitle(id, title)) -export const destroyTodo = (contractID, id) => write(contractID, removeTodo(id)) + write(contractID, 'setCompleted', id, completed) +export const renameTodo = (contractID, id, title) => write(contractID, 'setTitle', id, title) +export const destroyTodo = (contractID, id) => write(contractID, 'removeTodo', id) export const completeAllTodos = (contractID, completed) => - write(contractID, setAllCompleted(completed)) -export const clearCompletedTodos = (contractID) => write(contractID, removeCompleted()) + write(contractID, 'setAllCompleted', completed) +export const clearCompletedTodos = (contractID) => write(contractID, 'removeCompleted') diff --git a/src/components/AccountPanel.vue b/src/components/AccountPanel.vue new file mode 100644 index 0000000..22c3a08 --- /dev/null +++ b/src/components/AccountPanel.vue @@ -0,0 +1,80 @@ + + + diff --git a/src/components/App.vue b/src/components/App.vue index c84f601..3788387 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -1,7 +1,8 @@