Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.

Comment thread
akhileshthite marked this conversation as resolved.
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.
Expand All @@ -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
Expand All @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <contract-id> 0
Expand Down
34 changes: 34 additions & 0 deletions docs/data.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
127 changes: 106 additions & 21 deletions docs/login.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
akhileshthite marked this conversation as resolved.
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.
2 changes: 1 addition & 1 deletion docs/sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 3 additions & 2 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' } }
}
]
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "chelonia-todomvc",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"type": "module",
"engines": {
Expand Down
Loading
Loading