Skip to content
Open
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
13 changes: 8 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -944,7 +944,8 @@ jobs:
run: node relaytest.mjs

# The relay's accounts over HTTP, on a clock the script turns: keys,
# handles, sessions, the limits and the admin commands.
# handles, sessions, passkeys from a software authenticator, the
# limits and the admin commands.
- name: Accounts
working-directory: wasm/web
run: node accountstest.mjs
Expand Down Expand Up @@ -1015,7 +1016,8 @@ jobs:
# A room joined, not just the health line: that one answers before
# a room has been seeded from the image's gen/ and dsp/.
# Twice: as it starts with nothing set, and with CORS_ORIGIN, which
# is what turns its accounts on (and opens its database).
# is what turns its accounts on (and opens its database), and
# PASSKEY_RP_ID, its passkeys.
- name: It seeds and welcomes a room
run: |
join() {
Expand All @@ -1034,10 +1036,11 @@ jobs:
}
docker run -d --init --name relay -p 127.0.0.1:8787:8787 thinksynth-relay
docker run -d --init --name accounts -p 127.0.0.1:8788:8787 \
-e CORS_ORIGIN=https://pages.example.org thinksynth-relay
-e CORS_ORIGIN=https://pages.example.org \
-e PASSKEY_RP_ID=pages.example.org thinksynth-relay
for i in $(seq 30); do curl -fs 127.0.0.1:8787/ && curl -fs 127.0.0.1:8788/ && break; sleep 1; done
curl -fs 127.0.0.1:8787/ | jq -e '.accounts == false'
curl -fs 127.0.0.1:8788/ | jq -e '.accounts == true'
curl -fs 127.0.0.1:8787/ | jq -e '.accounts == false and .passkeys == null'
curl -fs 127.0.0.1:8788/ | jq -e '.accounts == true and .passkeys == "pages.example.org"'
join relay
join accounts
docker logs relay
Expand Down
4 changes: 4 additions & 0 deletions docker/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ services:
# has no accounts: the account API is not there, and a session in
# a hello is ignored.
CORS_ORIGIN: ${CORS_ORIGIN:-}
# The site's domain, which CORS_ORIGIN is on, for passkeys, and the
# name they are saved under; unset, there are none.
PASSKEY_RP_ID: ${PASSKEY_RP_ID:-}
PASSKEY_RP_NAME: ${PASSKEY_RP_NAME:-}
# nginx in front, appending the client's address to X-Forwarded-For,
# which the account API's rate limits read.
TRUST_PROXY: "1"
Expand Down
4 changes: 2 additions & 2 deletions docker/relay.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ COPY wasm/web/package.json wasm/web/package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force

COPY wasm/web/relay.mjs wasm/web/doc.js wasm/web/commands.js \
wasm/web/account.js wasm/web/accounts.mjs wasm/web/wordlist.mjs \
wasm/web/confusables.js ./
wasm/web/account.js wasm/web/accounts.mjs wasm/web/passkeys.mjs \
wasm/web/wordlist.mjs wasm/web/confusables.js ./
COPY gen /srv/thinksynth/gen
COPY dsp /srv/thinksynth/dsp

Expand Down
1 change: 1 addition & 0 deletions docker/relay.Dockerfile.dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
!wasm/web/commands.js
!wasm/web/account.js
!wasm/web/accounts.mjs
!wasm/web/passkeys.mjs
!wasm/web/wordlist.mjs
!wasm/web/confusables.js
!gen
Expand Down
5 changes: 3 additions & 2 deletions docs/JAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,8 +395,9 @@ Where it stands:
the mirror, as the solo page does, so an `osc::sample` instrument
sounds in a room.
- **Text chat**, a pane on the room page (section 4).
- **Accounts.** A handle nobody else can join as, logged in with an
eight-word key from the relay; guests are marked as guests. The
- **Accounts.** A handle nobody else can join as, logged in with a
passkey or an eight-word key from the relay; guests are marked as
guests. The
document socket is let in by a ticket from the room socket. Running
it is [RELAY.md](RELAY.md#accounts).
- **An invite link**, so a room is joined without typing its name, and
Expand Down
4 changes: 3 additions & 1 deletion docs/JAM_BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ else can take the handle, or one its owner renamed from in the last 30
days. Guests are still a name per session, marked as guests wherever
the room shows names. The document is no longer open to anyone who can
reach the relay: its socket needs a short-lived ticket the room socket
hands out. Roles, visibility and moderation within a room are not done.
hands out. An account logs in with a passkey, or with its key, which
is the way back in when a passkey is lost. Roles, visibility and
moderation within a room are not done.

## 1. The headless peer, and load

Expand Down
81 changes: 65 additions & 16 deletions docs/RELAY.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,21 @@ served from.
3. Copy `docker/compose.yaml` over, with a `.env` beside it:

CORS_ORIGIN=https://pages.example.org
PASSKEY_RP_ID=pages.example.org

(exactly an origin: scheme, host and any port, no path or trailing
slash; the relay will not start on anything else). Then
(`CORS_ORIGIN` exactly an origin: scheme, host and any port, no path
or trailing slash; `PASSKEY_RP_ID` the domain it is on, for passkeys
-- see [Passkeys](#passkeys) before picking it, and leave it out for
none. The relay will not start on anything else.) Then

docker compose -f compose.yaml pull
docker compose -f compose.yaml up -d

The relay listens on `127.0.0.1:8787`, for the proxy only, and
`curl 127.0.0.1:8787/` answers with its protocol, `accounts: true`
and its rooms.
`curl 127.0.0.1:8787/` answers with its protocol, `accounts: true`,
`passkeys` naming the RP ID, and its rooms. A `.env` the relay
refuses leaves the container restarting over and over;
`docker logs thinksynth-relay` says why.

4. Set the repository variable `JAM_RELAY` to `wss://relay.example.org`.
The next master build writes it into the Pages site's `config.json`,
Expand Down Expand Up @@ -113,7 +118,8 @@ Accounts belong to the relay the site's `config.json` names: a page sent
to another relay with `?relay=` joins it as a guest and keeps its
session to itself.

The image reads three variables, which `compose.yaml` passes on:
The image reads three variables, which `compose.yaml` passes on, and
two more for passkeys (below):

- `DB`: the file, `/data/relay.db` in the image. `compose.yaml` mounts
the named volume `accounts` on `/data`. Run from the tree, the relay
Expand All @@ -133,6 +139,47 @@ The image reads three variables, which `compose.yaml` passes on:
of `docker/nginx.conf`, which appends it. Set it only behind proxies
that do, or a client picks its own address.

### Passkeys

A passkey logs in to the same session a key does. With passkeys on, the
Account dialog makes an account with a passkey and shows the key once as
the recovery key; one can still be made with a key alone, and the key
always logs in. Adding a passkey takes the key; removing one takes only
a session, and ends none: replacing the key is what logs out every
other browser. Replacing the key removes every passkey the account
has, since whoever had the old key could have added any of them; the
dialog then offers to add one with the new key.

A passkey asks the authenticator to verify its user (a PIN, a
fingerprint) where it can, but does not require it. So a security key
with no PIN logs in whoever holds it, as the key logs in whoever has it;
and so does one with a PIN whose passkey was made under credProtect
level 1, which lets it answer without the PIN. A session a passkey
logged in to outlasts the passkey: removing it ends none, and replacing
the key is what ends them.

The relying party is the site the page is served from, not the relay:
WebAuthn binds a passkey to an RP ID, a domain the page's own origin
must be on. Two more variables:

- `PASSKEY_RP_ID`: that domain -- `pages.example.org` for a page at
`https://pages.example.org`, or `example.org`, which would let the
same passkeys work on the site's other subdomains if they ever took
them. `CORS_ORIGIN` must be on it, and is still the one origin whose
page can log in to this relay with a passkey; the relay will not start
otherwise. Unset, there are no passkeys: the health line's `passkeys`
is null and the page offers none. A page on another host (`127.0.0.1`
for a relay set up for `localhost`, say) offers none either.
- `PASSKEY_RP_NAME`: the name a passkey is saved under; `thinksynth` if
unset.

Both go in the `.env` beside `compose.yaml` (The first time, above).

Pick the domain for good. A passkey works only on the RP ID it was made
for, so moving the site to another domain, or changing `PASSKEY_RP_ID`,
orphans every passkey made so far; their owners log in with their keys
and add new ones.

The document socket is let in by a ticket in its query string, good for
five minutes. `docker/nginx.conf` logs requests by path alone so that
tickets stay out of the access log; keep that `log_format` if the file is
Expand Down Expand Up @@ -168,7 +215,9 @@ the image):
docker compose -f compose.yaml start relay

A restore goes back to the day of the backup, for better and worse:
keys replaced since work again and the new ones do not, sessions ended
keys replaced since work again and the new ones do not, passkeys
removed since -- by their owners, or with a replaced key -- log in
again and those added since do not, sessions ended
since -- logged out, revoked -- are live again, and an account banned
since is not. After one, ban those accounts again (`admin ban`), and
ask anyone who replaced a key because it was lost or seen to replace it
Expand All @@ -185,16 +234,16 @@ The admin commands run against the same file, beside the running relay:
docker exec thinksynth-relay node relay.mjs admin revoke <handle>
docker exec thinksynth-relay node relay.mjs admin delete <handle>

A ban ends the account's sessions and refuses its key until an unban; a
banned account cannot delete itself. `revoke` ends the sessions and
leaves the key working. A deleted account's handles -- its own and any it
was renamed from -- stay nobody's for 30 days from the delete, so a
name cannot be taken over to impersonate its owner; `delete <handle>
--free` frees them at once. `rename` takes the same handles an owner
could pick, so none starting with `guest-`. The relay looks
at the sessions behind its open rooms once a minute and closes those
that have ended, so a ban or a revoke empties the account out of every
room within the minute.
A ban ends the account's sessions and refuses its key and passkeys until
an unban; a banned account cannot delete itself. `revoke` ends the
sessions and leaves the key and passkeys working. A deleted account's
handles -- its own and any it was renamed from -- stay nobody's for 30
days from the delete, so a name cannot be taken over to impersonate its
owner; `delete <handle> --free` frees them at once. `rename` takes the
same handles an owner could pick, so none starting with `guest-`. The
relay looks at the sessions behind its open rooms once a minute and
closes those that have ended, so a ban or a revoke empties the account
out of every room within the minute.

## Not yet

Expand Down
Loading
Loading