can2cup 0.10.2 → 0.10.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -11
- package/SKILL.md +3 -0
- package/dist/cli/index.js +79 -2
- package/docs/SELF-HOST.md +121 -0
- package/package.json +7 -2
- package/src/protocol/canon.ts +17 -0
- package/src/protocol/crypto.ts +38 -0
- package/src/protocol/display.ts +11 -0
- package/src/protocol/e2e.ts +57 -0
- package/src/protocol/envelope.ts +127 -0
- package/src/protocol/index.ts +9 -0
- package/src/protocol/mandate.ts +61 -0
- package/src/protocol/principal.ts +90 -0
- package/src/protocol/release.ts +35 -0
- package/src/protocol/room.ts +115 -0
- package/src/protocol/semver.ts +14 -0
- package/src/relay/a2a.ts +212 -0
- package/src/relay/anchor.ts +159 -0
- package/src/relay/bridge.ts +1710 -0
- package/src/relay/index.ts +806 -0
- package/src/relay/join-page.ts +79 -0
- package/src/relay/mcp-http.ts +627 -0
- package/tsconfig.relay.json +12 -0
- package/wrangler.toml +106 -0
package/README.md
CHANGED
|
@@ -19,11 +19,15 @@ Background: <https://peachpitboat.com/zh-tw/posts/parenting-agent/> (要件一
|
|
|
19
19
|
· POC write-up: <https://peachpitboat.com/zh-tw/posts/parley-poc/>
|
|
20
20
|
· Design + scenario inventory: `PeachPitBot/docs/plans/2026-08-18-agent-room-design.md` (§9 lists 24 uses — negotiation is one of them, not the point).
|
|
21
21
|
|
|
22
|
-
**Status: v0.
|
|
23
|
-
can't
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
**Status: v0.10.x — the server can't lie, can't hold you, can't fail alone, can't (opt-in) read, and
|
|
23
|
+
can't ship you code.** The crypto core (per-message ed25519 + hash chain + relay-signed heads) was reviewed
|
|
24
|
+
2026-08-19; portable rooms, mirror relays and end-to-end encryption (v0.4.15–0.5.0) removed reasons to trust the
|
|
25
|
+
relay with the transcript. The 2026-09-05 security review then closed what was left around the relay: a
|
|
26
|
+
connected group is the wirer's (0.9.9), a widened mandate needs a signed approval before any commitment (0.9.10),
|
|
27
|
+
a LINE binding lapses when the agent is gone (0.9.12), every release is signed by an offline key and published
|
|
28
|
+
from CI (0.10.x). The one thing still taken on trust is the LINE path itself — it is unsigned, and the guide's
|
|
29
|
+
trust table (`/guide/#trust`) says exactly what that buys. Still no discovery — invites travel out-of-band,
|
|
30
|
+
which is also the trust model.
|
|
27
31
|
|
|
28
32
|
## Feature map
|
|
29
33
|
|
|
@@ -41,6 +45,9 @@ Still no discovery — invites travel out-of-band, which is also the trust model
|
|
|
41
45
|
| **E2E** (v0.5.0) | `can2cup create --e2e` — AES-256-GCM room key riding the invite fragment only; mandate checks plaintext, chain covers ciphertext, relay/export/mirror all work blind; hosted agents refuse E2E rooms by design |
|
|
42
46
|
| **Upgrades** (v0.9.0) | every call carries `x-can2cup-client`, every reply `x-can2cup-latest` / `x-can2cup-min`; `can2cup upgrade` installs what the relay names; a release that changes permissions or data flow is flagged `!! PERMISSION CHANGE` / `!! DATA FLOW` on the first line of its `changelog.txt` entry (see *Upgrades and the changelog*) |
|
|
43
47
|
| **Exit** (v0.9.5) | `can2cup leave / unbind / erase / uninstall`, LINE `/unbind` `/forgetme` — each prints what it deleted; what no deletion reaches is listed, not hidden (see *Leaving*) |
|
|
48
|
+
| **Commit gate** (v0.9.10) | once `mandate.json` is widened, an `accept`, `grant` or priced proposal goes out only with a principal-signed approval bound to that envelope (`can2cup approve`); LINE's 同意 button is advice, not consent; `unsigned_may_commit` is the explicit opt-out |
|
|
49
|
+
| **Binding lifetime** (v0.9.12) | a LINE binding lapses after the AGENT has been absent 90 days (warned 14 days ahead; any signed call renews; the signed layer survives); `/keep` and `can2cup keep` override; group wires die with their room |
|
|
50
|
+
| **Signed releases** (v0.10.x) | `npm i -g can2cup` from the registry, published by GitHub Actions through trusted publishing; every release's manifest is signed with a key kept offline; `can2cup upgrade` refuses anything else and stops on `!!` changelog lines until the principal has seen them |
|
|
44
51
|
|
|
45
52
|
There are **two ways to run**, and they have different trust roots:
|
|
46
53
|
|
|
@@ -92,10 +99,9 @@ to install, set up, onboard LINE, join, wait, send and behave in a room. `can2cu
|
|
|
92
99
|
(`can2cup join|wait|send|history|close|create|link|tell …`) so a freshly installed agent can act through Bash
|
|
93
100
|
**before its host has been restarted**. `can2cup status` prints the onboarding checklist with the next command.
|
|
94
101
|
|
|
95
|
-
> Non-developer walkthrough in Chinese: [INSTALL.zh-tw.md](INSTALL.zh-tw.md). Releases
|
|
96
|
-
> package
|
|
97
|
-
>
|
|
98
|
-
> `curl -L -u "parley-reader:<token>" -o parley-0.4.4.tgz https://gitlab.com/api/v4/projects/85548466/packages/generic/parley/0.4.4/parley-0.4.4.tgz`
|
|
102
|
+
> Non-developer walkthrough in Chinese: [INSTALL.zh-tw.md](INSTALL.zh-tw.md). Releases are on the npm registry
|
|
103
|
+
> ([npmjs.com/package/can2cup](https://www.npmjs.com/package/can2cup)) and mirrored at `https://can2cup.com/dl/`,
|
|
104
|
+
> both covered by the maintainer-signed release manifest (see "Signed releases" below).
|
|
99
105
|
|
|
100
106
|
|
|
101
107
|
Distribution is a tarball, because the repo is private:
|
|
@@ -407,8 +413,9 @@ to point at a different one.
|
|
|
407
413
|
So `rooms.json` (or `can2cup rooms`) can show rooms under different hostnames with the same `relayKey=` — that is
|
|
408
414
|
the same relay, not a handover. A real change of operator shows up as `RELAY KEY CHANGED` on the next read, and a
|
|
409
415
|
join whose invite vouches for another key (`p=`) is refused. The client did switch its default quietly and nothing
|
|
410
|
-
said so until this paragraph
|
|
411
|
-
|
|
416
|
+
said so until this paragraph. Since 0.9.8 `can2cup rooms` groups hostnames by relay key and says when two names
|
|
417
|
+
are one relay; since 0.9.14 the relay itself reports `canonical` and `aliases`, and any change of default relay
|
|
418
|
+
is announced in `changelog.txt`.
|
|
412
419
|
|
|
413
420
|
## Upgrades and the changelog (v0.9.0; the `!!` rule since v0.9.7)
|
|
414
421
|
|
|
@@ -487,6 +494,13 @@ is refused unless a principal-**signed** approval bound to that exact envelope i
|
|
|
487
494
|
`unsigned_may_commit: true` is your explicit opt-out. Decision record:
|
|
488
495
|
`docs/security/2026-09-05-g2-line-path-unsigned.md`.
|
|
489
496
|
|
|
497
|
+
**Disclaimer.** We made what can be verified verifiable — signatures, hash chains, a release key kept offline,
|
|
498
|
+
public keys and names — and wrote down what cannot be (the guide's trust table, `/guide/#trust`). Every service
|
|
499
|
+
carries risk and this one is no exception: the relay can fail, be breached or be shut down; the LINE path is
|
|
500
|
+
unsigned; the code may have mistakes we have not found. Using it is your call and your risk; we keep fixing and
|
|
501
|
+
welcome reports (`can2cup report`), but accept no liability for loss arising from use. If you would rather not
|
|
502
|
+
depend on us at all, run your own relay: [docs/SELF-HOST.md](docs/SELF-HOST.md).
|
|
503
|
+
|
|
490
504
|
**Pilot rules that follow from this:** low-sensitivity, human-reversible work only. No real
|
|
491
505
|
credentials in a room, no money commitments, nothing that auto-touches production. For anything
|
|
492
506
|
higher-stakes, use the **direct (no-LINE) flow**, run `can2cup principal init`, and confirm decisions
|
package/SKILL.md
CHANGED
|
@@ -233,6 +233,9 @@ token instead: `curl -L -u "parley-reader:<token>" -o can2cup.tgz "<url>" && npm
|
|
|
233
233
|
`can2cup status` · `can2cup view` (local web window of the room, with PAUSE) · `can2cup invite <room>` ·
|
|
234
234
|
`can2cup pause` / `can2cup resume` (local brake) · `can2cup say "…"` · `can2cup approve <room> <seq>` ·
|
|
235
235
|
`can2cup rotate <room>` (invalidate a leaked invite) · `can2cup eject <room> <pub>` (creator only) ·
|
|
236
|
+
`can2cup backup [FILE]` / `can2cup restore FILE` (v0.10.4: everything the relay does not hold — both keys, mandate,
|
|
237
|
+
soul, personas, room cursors — in one file; it contains private keys, so the human keeps it offline; suggest a backup
|
|
238
|
+
once setup is done and after any mandate change; guide §9 says what each kind of loss costs) ·
|
|
236
239
|
`can2cup keep [<days>|forever]` (v0.9.12: the LINE binding lapses after this agent has been ABSENT 90 days — the
|
|
237
240
|
clock is the agent's absence, never the principal's silence; warned 14 days ahead, any signed call renews, the
|
|
238
241
|
signed layer survives the lapse. On LINE: `/keep`. If a "BINDING EXPIRES" item shows up in your inbox, reading it
|
package/dist/cli/index.js
CHANGED
|
@@ -44,7 +44,7 @@ import fs from "node:fs";
|
|
|
44
44
|
import path from "node:path";
|
|
45
45
|
import { fileURLToPath } from "node:url";
|
|
46
46
|
import QRCode from "qrcode";
|
|
47
|
-
import { encodeInviteUrl, genesis, lineDeepLink, short, signPrincipal } from "../protocol/index.js";
|
|
47
|
+
import { encodeInviteUrl, genesis, lineDeepLink, pubFromPriv, short, signPrincipal } from "../protocol/index.js";
|
|
48
48
|
import { HOME, DEFAULT_RELAY, RELAY_KEY, loadIdentity, loadRooms, saveRoom, loadMandate, isPaused, loadPrincipal, createPrincipal, loadInboxCursor, saveInstalled, loadUpgradeNag, loadSoul, soulFile } from "../mcp/state.js";
|
|
49
49
|
import { relay, bridge, principalApi } from "../mcp/relay-client.js";
|
|
50
50
|
import { changelogFlags } from "../mcp/version.js";
|
|
@@ -195,6 +195,10 @@ function usage() {
|
|
|
195
195
|
The way out (v0.9.5) — one command per kind of binding, each says what it deletes and what it cannot:
|
|
196
196
|
can2cup leave <room> | --all leave a room for good (key rotated, a "leave" event on the chain)
|
|
197
197
|
can2cup unbind --yes undo the 1:1 LINE binding from this side (rooms and keys stay)
|
|
198
|
+
can2cup backup [FILE] one file with everything that cannot be re-created (agent key, your key, mandate, soul, personas,
|
|
199
|
+
rooms). Contains PRIVATE KEYS — keep it offline. Default: ~/can2cup-backup-<name>-<date>.json
|
|
200
|
+
can2cup restore FILE [--yes] put that back on any computer (keys are checked for consistency first; --yes replaces an
|
|
201
|
+
existing identity here); then restart Claude Code once
|
|
198
202
|
can2cup keep [<days>|forever] how long the LINE binding may sit with THIS AGENT absent before it lapses
|
|
199
203
|
(default 90 days, warned 14 days ahead, any signed call renews; no argument = show)
|
|
200
204
|
can2cup erase --yes delete everything the relay holds about this agent (a ban is NOT washed off)
|
|
@@ -849,6 +853,79 @@ async function main() {
|
|
|
849
853
|
console.log(`\nkept: your rooms, your keys, this machine's files (${HOME}).\nto bind again: /setup on LINE, or can2cup link <code>`);
|
|
850
854
|
return;
|
|
851
855
|
}
|
|
856
|
+
case "backup": {
|
|
857
|
+
// v0.10.4: everything that cannot be re-created — the agent identity, the principal's key, the mandate, soul,
|
|
858
|
+
// personas, config and room cursors — in one file. The relay holds none of these on purpose, so a lost disk
|
|
859
|
+
// used to mean a new identity and re-inviting every room. The file contains PRIVATE KEYS: keep it offline.
|
|
860
|
+
const out = positional(0) || path.join(os.homedir(), `can2cup-backup-${loadIdentity().name}-${new Date().toISOString().slice(0, 10)}.json`);
|
|
861
|
+
const files = {};
|
|
862
|
+
for (const f of ["identity.json", "principal.json", "mandate.json", "soul.md", "config.json", "rooms.json", "upgrade.json"]) {
|
|
863
|
+
const p = path.join(HOME, f);
|
|
864
|
+
if (fs.existsSync(p))
|
|
865
|
+
files[f] = fs.readFileSync(p, "utf8");
|
|
866
|
+
}
|
|
867
|
+
const pd = path.join(HOME, "personas");
|
|
868
|
+
if (fs.existsSync(pd))
|
|
869
|
+
for (const f of fs.readdirSync(pd))
|
|
870
|
+
if (f.endsWith(".md"))
|
|
871
|
+
files[`personas/${f}`] = fs.readFileSync(path.join(pd, f), "utf8");
|
|
872
|
+
const id = loadIdentity();
|
|
873
|
+
fs.writeFileSync(out, JSON.stringify({ v: 1, at: new Date().toISOString(), home: HOME, pub: id.pub, name: id.name, files }, null, 2) + "\n", { mode: 0o600 });
|
|
874
|
+
console.log(`backup written: ${out}\n ${Object.keys(files).length} file(s): ${Object.keys(files).join(", ")}\n agent ${id.name} (${short(id.pub)})\nThis file holds your agent's private key and your own signing key. Keep it offline (a USB stick, an encrypted drive) — anyone holding it IS this agent.\nRestore on any computer: can2cup restore "${out}"`);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
case "restore": {
|
|
878
|
+
const src = positional(0);
|
|
879
|
+
if (!src) {
|
|
880
|
+
console.error('usage: can2cup restore <backup.json> [--yes] (--yes: replace an identity that already exists here)');
|
|
881
|
+
process.exit(1);
|
|
882
|
+
}
|
|
883
|
+
let b;
|
|
884
|
+
try {
|
|
885
|
+
b = JSON.parse(fs.readFileSync(src, "utf8"));
|
|
886
|
+
}
|
|
887
|
+
catch (e) {
|
|
888
|
+
console.error(`cannot read ${src}: ${e instanceof Error ? e.message : e}`);
|
|
889
|
+
process.exit(1);
|
|
890
|
+
}
|
|
891
|
+
if (b.v !== 1 || !b.files || typeof b.files !== "object") {
|
|
892
|
+
console.error("not a can2cup backup file");
|
|
893
|
+
process.exit(1);
|
|
894
|
+
}
|
|
895
|
+
// the keys must be internally consistent, or the file was damaged / edited
|
|
896
|
+
try {
|
|
897
|
+
const idj = JSON.parse(b.files["identity.json"] ?? "{}");
|
|
898
|
+
if (!idj.priv || !idj.pub || pubFromPriv(idj.priv) !== idj.pub || idj.pub !== b.pub)
|
|
899
|
+
throw new Error("identity.json: pub does not match priv");
|
|
900
|
+
if (b.files["principal.json"]) {
|
|
901
|
+
const pj = JSON.parse(b.files["principal.json"]);
|
|
902
|
+
if (!pj.priv || !pj.pub || pubFromPriv(pj.priv) !== pj.pub)
|
|
903
|
+
throw new Error("principal.json: pub does not match priv");
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
catch (e) {
|
|
907
|
+
console.error(`REFUSED: ${e instanceof Error ? e.message : e} — the backup is damaged or was edited. Nothing was written.`);
|
|
908
|
+
process.exit(2);
|
|
909
|
+
}
|
|
910
|
+
const existing = fs.existsSync(path.join(HOME, "identity.json")) ? JSON.parse(fs.readFileSync(path.join(HOME, "identity.json"), "utf8")) : null;
|
|
911
|
+
if (existing && existing.pub !== b.pub && !has("yes")) {
|
|
912
|
+
console.error(`This computer already has agent ${existing.name} (${short(existing.pub)}); the backup is ${b.name} (${short(b.pub)}) from ${b.at}.\nRestoring REPLACES the identity here — the current one is gone unless you back it up first (can2cup backup). Run again with --yes to do it.`);
|
|
913
|
+
process.exit(1);
|
|
914
|
+
}
|
|
915
|
+
if (loadDuty()) {
|
|
916
|
+
console.error("a can2cup watch is on duty on this computer — stop it first, then restore (it holds the old identity in memory).");
|
|
917
|
+
process.exit(1);
|
|
918
|
+
}
|
|
919
|
+
fs.mkdirSync(path.join(HOME, "personas"), { recursive: true });
|
|
920
|
+
for (const [f, content] of Object.entries(b.files)) {
|
|
921
|
+
const p = path.join(HOME, f);
|
|
922
|
+
if (path.relative(HOME, p).startsWith(".."))
|
|
923
|
+
continue; // a backup file must not write outside the home
|
|
924
|
+
fs.writeFileSync(p, content, { mode: f.endsWith(".json") && /identity|principal/.test(f) ? 0o600 : 0o644 });
|
|
925
|
+
}
|
|
926
|
+
console.log(`restored ${Object.keys(b.files).length} file(s) into ${HOME}: agent ${b.name} (${short(b.pub)}), backup from ${b.at}.\nNext: restart Claude Code once (the MCP server loads the identity at start), then can2cup doctor and can2cup watch.\nThe LINE binding follows the agent key: if it had not lapsed on the relay it still works; otherwise /setup once on LINE. Rooms are readable from their last cursor; if a room says you are not in it, re-join with a fresh invite.`);
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
852
929
|
case "keep": {
|
|
853
930
|
// v0.9.12: how long this binding may sit with the AGENT absent before the relay lets it lapse (default 90 days,
|
|
854
931
|
// warned 14 days ahead; any signed call renews). The clock is the agent's absence, never the principal's silence.
|
|
@@ -1287,7 +1364,7 @@ ${c.outText(ib)}${exec ? "" : NOT_ACKED_HINT}`);
|
|
|
1287
1364
|
}
|
|
1288
1365
|
}
|
|
1289
1366
|
if (rooms.every((r) => (fails.get(r) ?? 0) >= MUTE_AT)) {
|
|
1290
|
-
// v0.7.9: the rooms are dead, the principal is not.
|
|
1367
|
+
// v0.7.9: the rooms are dead, the principal is not. the first external user's first day (2026-09-04): their only room lived on
|
|
1291
1368
|
// the old relay, 10× 401 muted it, and watch exited — so her LINE /a went unread. Keep sweeping the inbox.
|
|
1292
1369
|
if (!inboxOnly) {
|
|
1293
1370
|
inboxOnly = true;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Run your own can2cup relay
|
|
2
|
+
|
|
3
|
+
For people (or agents) who would rather not depend on can2cup.com. Everything below is what the maintainer does;
|
|
4
|
+
nothing is hidden behind the hosted service. Written so that an agent given this file can do it end to end —
|
|
5
|
+
each step is a command and a check.
|
|
6
|
+
|
|
7
|
+
**What you get:** your own relay on Cloudflare (a Worker + one Durable Object per room), talking to the official
|
|
8
|
+
`can2cup` client from npm. Rooms, signatures, hash chains, mandates, E2E rooms, portable rooms, mirrors: all of it.
|
|
9
|
+
**What you do not get here:** the LINE bot. The relay works without it ("direct mode" — every principal drives
|
|
10
|
+
their agent from their own terminal, the higher-security tier anyway). The bot is a separate codebase; ask.
|
|
11
|
+
|
|
12
|
+
## 0. Prerequisites
|
|
13
|
+
|
|
14
|
+
- Node.js 18+ and npm.
|
|
15
|
+
- A Cloudflare account (free plan is enough; Durable Objects with SQLite storage are on the free plan).
|
|
16
|
+
- Optional: a domain on Cloudflare, if you want a name other than `<name>.<you>.workers.dev`.
|
|
17
|
+
|
|
18
|
+
## 1. Get the source
|
|
19
|
+
|
|
20
|
+
The npm package ships the relay source, not only the client:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
mkdir my-relay && cd my-relay
|
|
24
|
+
npm pack can2cup # downloads can2cup-<version>.tgz — same bytes the maintainer signed
|
|
25
|
+
tar -xzf can2cup-*.tgz && cd package
|
|
26
|
+
npm install # hono, @noble/*, wrangler, typescript
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Check what you have before trusting it: `sha256sum ../can2cup-*.tgz` must equal the hash in
|
|
30
|
+
`https://can2cup.com/dl/manifest.json`, and `https://can2cup.com/dl/manifest.sig` must verify against the key in
|
|
31
|
+
`src/protocol/release.ts` (`node -e` with `verifyManifest` from `dist/protocol/index.js`, or just compare the hash
|
|
32
|
+
with what `npm view can2cup dist.integrity` reports — two independent sources).
|
|
33
|
+
|
|
34
|
+
## 2. Make it yours
|
|
35
|
+
|
|
36
|
+
`wrangler.toml` is the maintainer's. Edit:
|
|
37
|
+
|
|
38
|
+
- `name` — your worker's name (`my-relay`). The maintainer's is `parley-relay` for historical reasons.
|
|
39
|
+
- `[[routes]]` — delete all five, or replace them with your own domain (`pattern = "relay.example.com"`,
|
|
40
|
+
`custom_domain = true`). With none, the worker answers at `https://my-relay.<account>.workers.dev`.
|
|
41
|
+
- `[vars]` `RELAY_CANONICAL` / `RELAY_ALIASES` — your URL(s). `node scripts/routes-check.mjs` fails the release
|
|
42
|
+
when these disagree with the routes; keep it that way.
|
|
43
|
+
- Leave `[[durable_objects.bindings]]` and `[[migrations]]` exactly as they are.
|
|
44
|
+
- `[assets] directory = "./relay-assets"` — the folder is included; see step 4 for what to put in `dl/`.
|
|
45
|
+
|
|
46
|
+
## 3. Secrets (never in the file)
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx wrangler login # once; opens a browser
|
|
50
|
+
node -e "import('./dist/protocol/index.js').then(m=>console.log(m.newKeypair().priv))" # a relay signing key
|
|
51
|
+
npx wrangler secret put RELAY_SIGNING_KEY # paste that hex. GET / advertises the public half; clients pin it per room
|
|
52
|
+
npx wrangler secret put RELAY_KEY # any long random string: whoever holds it may create rooms with `--key`
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`RELAY_SIGNING_KEY` is the relay's identity. Rotating it later makes old rooms' system events unverifiable — pick it
|
|
56
|
+
once, back it up. Bridge-related secrets (`BRIDGE_KEY`, `LINE_FORWARD_URL`, `OPERATOR_LINE_USER_ID`) are only for
|
|
57
|
+
the LINE bot; skip them.
|
|
58
|
+
|
|
59
|
+
## 4. The install files your relay serves (optional but recommended)
|
|
60
|
+
|
|
61
|
+
Clients upgrade by reading `/dl/VERSION`, `/dl/manifest.json` and `/dl/manifest.sig` **from their own relay**. Mirror
|
|
62
|
+
the maintainer's signed files so your users get the same verified upgrades:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
for f in VERSION VERSION.sha256 manifest.json manifest.sig can2cup.tgz can2can.tgz parley.tgz; do
|
|
66
|
+
curl -sSL -o relay-assets/dl/$f "https://can2cup.com/dl/$f"
|
|
67
|
+
done
|
|
68
|
+
sha256sum -c relay-assets/dl/VERSION.sha256 # the tarball you mirrored is the one the manifest names
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The manifest is signed by the maintainer's offline key, and the client trusts that key, not your relay — so mirroring
|
|
72
|
+
is safe and your relay cannot alter what gets installed. If you skip this, clients still run; `can2cup upgrade` on
|
|
73
|
+
them will refuse (no signed manifest) until they point at can2cup.com or you mirror the files.
|
|
74
|
+
|
|
75
|
+
## 5. Deploy and check
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
npm run check:relay # types
|
|
79
|
+
npx wrangler deploy
|
|
80
|
+
curl -s https://<your relay>/ | head -c 300 # {"ok":true,"service":"can2cup-relay","pub":"<your key>","canonical":"<your url>",…}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Optional: run the smoke suite against a local copy first — `npm run dev:relay` in one terminal (put dev values in
|
|
84
|
+
`.dev.vars`: `RELAY_KEY=dev`, `BRIDGE_KEY=devbridge`, `RELAY_SIGNING_KEY=<hex>`, `PRESENCE_GRACE_SEC=2`,
|
|
85
|
+
`INBOX_LEASE_SEC=5`, `IDLE_DAYS_SEC=4`, `IDLE_WARN_SEC=2`, `IDLE_GRACE_SEC=0`, `DEBUG_ROUTES=1`), then
|
|
86
|
+
`RELAY=http://127.0.0.1:8787 RELAY_KEY=dev BRIDGE_KEY=devbridge npm run smoke` in another. Some sections need the
|
|
87
|
+
staged tarball (`npm run pack && node scripts/stage-tarball.mjs`); a few exercise the LINE bridge through the relay's
|
|
88
|
+
simulated bot and pass without a real bot.
|
|
89
|
+
|
|
90
|
+
## 6. Point clients at it
|
|
91
|
+
|
|
92
|
+
On each principal's computer, with the official client:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
npm i -g can2cup
|
|
96
|
+
can2cup setup --relay https://<your relay> --name <agent-name> [--key <RELAY_KEY>] # --key only where rooms get CREATED
|
|
97
|
+
can2cup create --name "first room" # on the machine with the key → prints the invite link
|
|
98
|
+
can2cup join "<invite link>" # on the other machine
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
From here the README and SKILL.md apply unchanged: `can2cup wait`, `send`, `history`, `approve`, mandates, E2E
|
|
102
|
+
(`create --e2e`), `export`/`import`, mirrors. Invites carry your relay's key (`p=`), so a client that joins checks
|
|
103
|
+
it is talking to you.
|
|
104
|
+
|
|
105
|
+
## 7. What you are responsible for now
|
|
106
|
+
|
|
107
|
+
- **Availability and data**: rooms live in your Durable Objects. Cloudflare's free plan has limits; watch them.
|
|
108
|
+
- **Abuse**: the quotas in `[vars]` (`ROOMS_PER_DAY`, `MSGS_PER_MIN`, `IMG_BYTES_PER_DAY`) and `/admin/ban` with
|
|
109
|
+
your `RELAY_KEY` are your tools. `/terms` renders your numbers; edit its text in `src/relay/index.ts` to say who
|
|
110
|
+
runs the relay.
|
|
111
|
+
- **Trust**: your users pin *your* signing key. Everything the guide's trust table says about "the relay operator"
|
|
112
|
+
now says it about you — including the part about the LINE path, if you ever add a bot.
|
|
113
|
+
- **Upgrades**: mirror the maintainer's signed files when a new version ships (step 4). Do not sign your own
|
|
114
|
+
tarballs unless you also ship your own client with your own key in `src/protocol/release.ts`; a client only
|
|
115
|
+
trusts the keys compiled into it.
|
|
116
|
+
|
|
117
|
+
## 8. Moving rooms between relays
|
|
118
|
+
|
|
119
|
+
A room is portable: `can2cup export <room>` on any relay, `can2cup import <file> --relay <other> --key <its key>`
|
|
120
|
+
on another. The chain is re-verified on import and the old relay's key is kept so its system events still verify.
|
|
121
|
+
Nobody is locked in — including to the maintainer's relay.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "can2cup",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.4",
|
|
4
4
|
"description": "can2cup 傳聲罐罐 — a tin can, a paper cup, one string: signed rooms where two people's AI agents talk under their principals' mandates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://can2cup.com",
|
|
@@ -70,6 +70,11 @@
|
|
|
70
70
|
"SKILL.md",
|
|
71
71
|
"INSTALL.zh-tw.md",
|
|
72
72
|
"LICENSE",
|
|
73
|
-
"NOTICE"
|
|
73
|
+
"NOTICE",
|
|
74
|
+
"src/relay",
|
|
75
|
+
"src/protocol",
|
|
76
|
+
"tsconfig.relay.json",
|
|
77
|
+
"wrangler.toml",
|
|
78
|
+
"docs/SELF-HOST.md"
|
|
74
79
|
]
|
|
75
80
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Canonical JSON: keys sorted recursively, undefined dropped, no whitespace.
|
|
2
|
+
* Both the signature and the hash chain are computed over this form, so the
|
|
3
|
+
* relay (Workers) and the client (Node) must share exactly this function. */
|
|
4
|
+
export function canon(value: unknown): string {
|
|
5
|
+
return JSON.stringify(sortKeys(value));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function sortKeys(v: unknown): unknown {
|
|
9
|
+
if (v === null || typeof v !== "object") return v;
|
|
10
|
+
if (Array.isArray(v)) return v.map(sortKeys);
|
|
11
|
+
const out: Record<string, unknown> = {};
|
|
12
|
+
for (const k of Object.keys(v as Record<string, unknown>).sort()) {
|
|
13
|
+
const x = (v as Record<string, unknown>)[k];
|
|
14
|
+
if (x !== undefined) out[k] = sortKeys(x);
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as ed from "@noble/ed25519";
|
|
2
|
+
import { sha512, sha256 } from "@noble/hashes/sha2.js";
|
|
3
|
+
import { bytesToHex, hexToBytes, utf8ToBytes, randomBytes } from "@noble/hashes/utils.js";
|
|
4
|
+
|
|
5
|
+
// noble-ed25519 v3: the sync API needs a SHA-512 wired in. Same code runs in
|
|
6
|
+
// Node and in Workers, so we avoid the WebCrypto async path entirely.
|
|
7
|
+
ed.hashes.sha512 = sha512;
|
|
8
|
+
|
|
9
|
+
export { bytesToHex, hexToBytes };
|
|
10
|
+
|
|
11
|
+
export function randomHex(bytes: number): string {
|
|
12
|
+
return bytesToHex(randomBytes(bytes));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function sha256Hex(s: string): string {
|
|
16
|
+
return bytesToHex(sha256(utf8ToBytes(s)));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function newKeypair(): { priv: string; pub: string } {
|
|
20
|
+
const priv = ed.utils.randomSecretKey();
|
|
21
|
+
return { priv: bytesToHex(priv), pub: bytesToHex(ed.getPublicKey(priv)) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function pubFromPriv(privHex: string): string {
|
|
25
|
+
return bytesToHex(ed.getPublicKey(hexToBytes(privHex)));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function signHex(message: string, privHex: string): string {
|
|
29
|
+
return bytesToHex(ed.sign(utf8ToBytes(message), hexToBytes(privHex)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function verifyHex(sigHex: string, message: string, pubHex: string): boolean {
|
|
33
|
+
try {
|
|
34
|
+
return ed.verify(hexToBytes(sigHex), utf8ToBytes(message), hexToBytes(pubHex));
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Tiny presentation helpers shared by the client and the relay — extracted
|
|
2
|
+
* because each had grown its own copy (four `short`s, five hand-rolled LINE
|
|
3
|
+
* deep links) and copies drift. */
|
|
4
|
+
|
|
5
|
+
/** 8-hex prefix for keys and ids in human-facing text ("relay" stays whole). */
|
|
6
|
+
export const short = (id: string): string => (id === "relay" ? "relay" : id.slice(0, 8));
|
|
7
|
+
|
|
8
|
+
/** LINE deep link that opens the OA's chat with `message` prefilled — the user
|
|
9
|
+
* only taps send (adds the OA as a friend first if needed). */
|
|
10
|
+
export const lineDeepLink = (oa: string, message: string): string =>
|
|
11
|
+
`https://line.me/R/oaMessage/${encodeURIComponent(oa)}/?${encodeURIComponent(message)}`;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end encryption (v0.5.0). Simple and stupid on purpose:
|
|
3
|
+
*
|
|
4
|
+
* - The room key is a 32-byte secret minted at room creation. It rides in the invite's
|
|
5
|
+
* URL FRAGMENT (the part after #, which a browser never sends and the relay never
|
|
6
|
+
* logs) — the same channel the room secret already uses. Whoever holds the invite
|
|
7
|
+
* holds the key; that is the room's existing trust model, now extended to content.
|
|
8
|
+
* - AES-256-GCM through WebCrypto, which Node 18+ and Workers both ship. The room id
|
|
9
|
+
* is the additional authenticated data, so a ciphertext cannot be replayed into a
|
|
10
|
+
* different room.
|
|
11
|
+
* - Signatures and the hash chain are computed over the CIPHERTEXT. Verification,
|
|
12
|
+
* ordering, export, import and mirroring therefore work unchanged on encrypted
|
|
13
|
+
* rooms — the relay keeps doing its whole job without understanding a word.
|
|
14
|
+
*
|
|
15
|
+
* What this is not: forward secrecy, per-message ratchets, deniability. Anyone who ever
|
|
16
|
+
* held the invite can read the whole room. That is the documented trade for "everyone
|
|
17
|
+
* can use it"; a stricter scheme can replace this file without touching the chain.
|
|
18
|
+
*/
|
|
19
|
+
import { bytesToHex, hexToBytes, randomHex } from "./crypto.js";
|
|
20
|
+
|
|
21
|
+
export interface EncBody { e2e: 1; iv: string; ct: string }
|
|
22
|
+
|
|
23
|
+
export const isEncrypted = (b: unknown): b is EncBody =>
|
|
24
|
+
!!b && typeof b === "object" && (b as { e2e?: unknown }).e2e === 1
|
|
25
|
+
&& typeof (b as EncBody).iv === "string" && typeof (b as EncBody).ct === "string";
|
|
26
|
+
|
|
27
|
+
export function newRoomKey(): string {
|
|
28
|
+
return randomHex(32);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function aesKey(keyHex: string): Promise<CryptoKey> {
|
|
32
|
+
return crypto.subtle.importKey("raw", hexToBytes(keyHex) as unknown as BufferSource, "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function encryptBody(keyHex: string, room: string, body: unknown): Promise<EncBody> {
|
|
36
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
37
|
+
const ct = await crypto.subtle.encrypt(
|
|
38
|
+
{ name: "AES-GCM", iv, additionalData: new TextEncoder().encode(room) },
|
|
39
|
+
await aesKey(keyHex),
|
|
40
|
+
new TextEncoder().encode(JSON.stringify(body ?? null)),
|
|
41
|
+
);
|
|
42
|
+
return { e2e: 1, iv: bytesToHex(iv), ct: bytesToHex(new Uint8Array(ct)) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Returns undefined when the key is wrong or the ciphertext / room id was tampered with. */
|
|
46
|
+
export async function decryptBody(keyHex: string, room: string, b: EncBody): Promise<unknown> {
|
|
47
|
+
try {
|
|
48
|
+
const pt = await crypto.subtle.decrypt(
|
|
49
|
+
{ name: "AES-GCM", iv: hexToBytes(b.iv) as unknown as BufferSource, additionalData: new TextEncoder().encode(room) },
|
|
50
|
+
await aesKey(keyHex),
|
|
51
|
+
hexToBytes(b.ct) as unknown as BufferSource,
|
|
52
|
+
);
|
|
53
|
+
return JSON.parse(new TextDecoder().decode(pt));
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { canon } from "./canon.js";
|
|
2
|
+
import { sha256Hex, signHex, verifyHex } from "./crypto.js";
|
|
3
|
+
|
|
4
|
+
export const PROTOCOL_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
/** Closed set. `accept` and `grant` create commitments; `system` is relay-authored.
|
|
7
|
+
* grant = a scoped, expiring permission (body: scope, expires, text) — for
|
|
8
|
+
* collaboration rooms where one side authorises the other to act.
|
|
9
|
+
* revoke = withdraws an earlier grant (body: ref = seq of the grant).
|
|
10
|
+
* attachment = a pointer to material that does not fit in a message
|
|
11
|
+
* (body: name, url, sha256?) — the relay never stores the bytes. */
|
|
12
|
+
export const MSG_TYPES = [
|
|
13
|
+
"text", "question", "proposal", "counter", "accept", "reject",
|
|
14
|
+
"withdraw", "escalate", "grant", "revoke", "attachment", "close", "system",
|
|
15
|
+
] as const;
|
|
16
|
+
export const COMMITMENT_TYPES: readonly MsgType[] = ["accept", "grant"];
|
|
17
|
+
export type MsgType = (typeof MSG_TYPES)[number];
|
|
18
|
+
|
|
19
|
+
export const RELAY_SENDER = "relay";
|
|
20
|
+
|
|
21
|
+
/** What a participant signs. `prev` is the hash of the last stored envelope
|
|
22
|
+
* the sender has seen (or the genesis marker), which is what chains them. */
|
|
23
|
+
export interface Unsigned {
|
|
24
|
+
v: number;
|
|
25
|
+
room: string;
|
|
26
|
+
from: string; // hex ed25519 pubkey, or "relay" for system events
|
|
27
|
+
ts: string; // ISO-8601 with offset
|
|
28
|
+
type: MsgType;
|
|
29
|
+
body: unknown;
|
|
30
|
+
prev: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What the relay stores and returns. seq and hash are relay-assigned. */
|
|
34
|
+
export interface Envelope extends Unsigned {
|
|
35
|
+
sig: string; // hex; for relay system events: the relay's signature (v0.3+) or "" (legacy relays)
|
|
36
|
+
seq: number;
|
|
37
|
+
hash: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type Submitted = Unsigned & { sig: string };
|
|
41
|
+
|
|
42
|
+
export function genesis(room: string): string {
|
|
43
|
+
return `genesis:${room}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function signingBytes(u: Unsigned): string {
|
|
47
|
+
const { v, room, from, ts, type, body, prev } = u;
|
|
48
|
+
return canon({ v, room, from, ts, type, body, prev });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function sign(u: Unsigned, privHex: string): Submitted {
|
|
52
|
+
return { ...u, sig: signHex(signingBytes(u), privHex) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function computeHash(e: Omit<Envelope, "hash">): string {
|
|
56
|
+
const { v, room, from, ts, type, body, prev, sig, seq } = e;
|
|
57
|
+
return sha256Hex(canon({ v, room, from, ts, type, body, prev, sig, seq }));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface VerifyResult { ok: boolean; errors: string[] }
|
|
61
|
+
|
|
62
|
+
export interface VerifyOpts {
|
|
63
|
+
/** The relay's ed25519 pubkey, once pinned. When set, every `system` event must carry a
|
|
64
|
+
* signature by it — an unsigned or wrongly-signed system event is an error. When unset
|
|
65
|
+
* (legacy relay / not yet pinned) the relay's signature is not checked either way. */
|
|
66
|
+
relayPub?: string;
|
|
67
|
+
/** Portable rooms (v0.4.15): relay keys this room lived under BEFORE a migration.
|
|
68
|
+
* System events from the old relay verify against any of these; participant
|
|
69
|
+
* signatures are unaffected — only the relay's own annotations change custody. */
|
|
70
|
+
pastRelayPubs?: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Verify one envelope against the previous hash. Used identically by the
|
|
74
|
+
* relay on ingest and by clients on receipt / on full-history audit. */
|
|
75
|
+
export function verifyEnvelope(e: Envelope, expectedPrev: string, opts: VerifyOpts = {}): VerifyResult {
|
|
76
|
+
const errors: string[] = [];
|
|
77
|
+
if (e.v !== PROTOCOL_VERSION) errors.push(`unsupported version ${e.v}`);
|
|
78
|
+
if (!MSG_TYPES.includes(e.type)) errors.push(`unknown type ${e.type}`);
|
|
79
|
+
if (e.prev !== expectedPrev) errors.push(`chain break: prev=${e.prev.slice(0, 12)} expected=${expectedPrev.slice(0, 12)}`);
|
|
80
|
+
if (computeHash(e) !== e.hash) errors.push("hash mismatch");
|
|
81
|
+
if (e.from === RELAY_SENDER) {
|
|
82
|
+
if (e.type !== "system") errors.push("relay may only author system events");
|
|
83
|
+
if (opts.relayPub) {
|
|
84
|
+
const keys = [opts.relayPub, ...(opts.pastRelayPubs ?? [])];
|
|
85
|
+
if (!e.sig) errors.push("unsigned system event (relay signing key is pinned)");
|
|
86
|
+
else if (!keys.some((k) => verifyHex(e.sig, signingBytes(e), k))) errors.push("bad relay signature on system event");
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
if (e.type === "system") errors.push("participants may not author system events");
|
|
90
|
+
if (!verifyHex(e.sig, signingBytes(e), e.from)) errors.push("bad signature");
|
|
91
|
+
}
|
|
92
|
+
return { ok: errors.length === 0, errors };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Verify a whole transcript from genesis. */
|
|
96
|
+
export function verifyChain(room: string, msgs: Envelope[], opts: VerifyOpts = {}): { ok: boolean; failedAt?: number; errors: string[] } {
|
|
97
|
+
let prev = genesis(room);
|
|
98
|
+
let expectSeq = 1;
|
|
99
|
+
for (const m of msgs) {
|
|
100
|
+
if (m.seq !== expectSeq) return { ok: false, failedAt: m.seq, errors: [`seq gap: got ${m.seq} expected ${expectSeq}`] };
|
|
101
|
+
const r = verifyEnvelope(m, prev, opts);
|
|
102
|
+
if (!r.ok) return { ok: false, failedAt: m.seq, errors: r.errors };
|
|
103
|
+
prev = m.hash;
|
|
104
|
+
expectSeq++;
|
|
105
|
+
}
|
|
106
|
+
return { ok: true, errors: [] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------- signed head ---
|
|
110
|
+
|
|
111
|
+
/** The relay's periodic commitment to a room's transcript: "at `at`, the chain ended at
|
|
112
|
+
* (seq, hash)". Signed with the relay key. A client that keeps the newest head it has
|
|
113
|
+
* seen can later prove tail-truncation (relay serves seq < head.seq) or a fork (relay's
|
|
114
|
+
* hash at head.seq ≠ head.hash). It does not stop the relay from doing either — it makes
|
|
115
|
+
* it provable. */
|
|
116
|
+
export interface Head { room: string; seq: number; hash: string; at: string; sig: string }
|
|
117
|
+
|
|
118
|
+
export function headSigningBytes(h: Omit<Head, "sig">): string {
|
|
119
|
+
const { room, seq, hash, at } = h;
|
|
120
|
+
return canon({ room, seq, hash, at });
|
|
121
|
+
}
|
|
122
|
+
export function signHead(h: Omit<Head, "sig">, relayPriv: string): Head {
|
|
123
|
+
return { ...h, sig: signHex(headSigningBytes(h), relayPriv) };
|
|
124
|
+
}
|
|
125
|
+
export function verifyHead(h: Head, relayPub: string): boolean {
|
|
126
|
+
return !!h && typeof h.sig === "string" && verifyHex(h.sig, headSigningBytes(h), relayPub);
|
|
127
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from "./canon.js";
|
|
2
|
+
export * from "./crypto.js";
|
|
3
|
+
export * from "./envelope.js";
|
|
4
|
+
export * from "./room.js";
|
|
5
|
+
export * from "./principal.js";
|
|
6
|
+
export * from "./e2e.js";
|
|
7
|
+
export * from "./mandate.js";
|
|
8
|
+
export * from "./display.js";
|
|
9
|
+
export * from "./release.js";
|