OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / cfc6af8

0.9.94: the operator surface has an identity layer
⛔ THE SAME ONE THE REST OF THE STACK USES, not a password. `Browsers` from favela-peer holds the registry: an ANProto keypair proves itself against a single-use challenge, the FIRST one claims the instance, every one after needs an invite, and a revoke lands on the NEXT request because the cookie is not the authority — the registry is re-asked per request. A bearer password would have been fewer moving parts and a seventh way to authenticate in one monorepo. ⛔ THE FIRST CLAIM NEEDS THE BOOTSTRAP TOKEN, printed once at startup and only while the bridge is unclaimed. Plain first-claim TOFU is safe only while the surface binds loopback — browsers.js says so itself — and logging a dead secret on every boot teaches an operator to ignore the line that matters. Generated per boot and held in memory: writing it to disk would make a standing secret of something needed exactly once. ⛔ AND IT STAYS ON LOOPBACK DESPITE NOW HAVING AUTH — not caution, a measured constraint. static/anproto-client.js hashes with `crypto.subtle`, which browsers expose only in a SECURE CONTEXT; its own comment says "works on localhost + HTTPS". Bound to a public port over plain HTTP this would serve a login page whose crypto is undefined in the browser that loaded it. It is published through the proxy that already terminates TLS for this host. The browser half is vendored from favela-surface — the same anproto-client + noble-ed25519 + noble-sha512 that favela-surface and pear-git already serve, rather than a seventh copy of the wire format. Static is a FIXED LIST of three names, never a directory join: a path taken off the wire and joined to a root is a traversal bug, and the test asserts `/static/../lib/admin.js` and friends 404. The gate lives in one file so there is one place to read to know what is protected. Sign-in and static are reachable without a session; everything else redirects to sign-in — a redirect rather than a 403 because the operator's next act is always to sign in. ⚠ openCorestore, not `new Corestore` — the same gotcha already recorded above the record store: `instanceof Corestore` fails across duplicated module copies and surfaces as "path… undefined". 1013/1013 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cfc6af8031478909545b7eca9f6090c675ef3845
by plankatron <fleeky@gmail.com> · 2026-09-11T23:48:04.000Z

Changed files (9) +662 -100

.pgh-lock.json +95 -95
……@@ -1,9 +1,9 @@
11{
22 "name": "ap-bridge",
3 "version": "0.9.91",
3 "version": "0.9.93",
44 "runtime": "bare",
5 "rootTreeHash": "4373422f9b5b8c299b8a34e7104da5dae1e608935ac858b423df236dc2474db4",
6 "publishedAt": "2026-09-11T22:03:58.277Z",
5 "rootTreeHash": "6d10ca5949e4d7523e108ecbe1d6f04a4ece408318276e1492686c8cf5cd6c3f",
6 "publishedAt": "2026-09-11T23:30:52.409Z",
77 "deps": [
88 {
99 "name": "@hyperswarm/secret-stream",
……@@ -2396,8 +2396,8 @@
23962396 {
23972397 "name": "favela-peer",
23982398 "version": "0.0.56",
2399 "tree": "8ea15d633438bd0f3d4dc69babecd0069ba33c3e7e63de8dd5d428f9530062b7",
2400 "normHash": "42db39cc01ba6f0424f2302b28fe5a523e082501ae708961cd16f2a025faada2",
2399 "tree": "fb3415c5c01dcc8d31eb0b218ef450eb0bbfa85ff41de769763f4b82666af874",
2400 "normHash": "2a447b52145af4ac66941b9cc7f8bfe8852bb18ce031bbae2bd63df955f47756",
24012401 "workspace": true
24022402 },
24032403 {
……@@ -4004,8 +4004,8 @@
40044004 "tree": "38f44af48b2d59b46f1b5db31b81d02f3ca8b8bad6bc0fb9fffff698241f5e37"
40054005 }
40064006 ],
4007 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4008 "acceptedAt": "2026-09-11T22:03:58.310Z"
4007 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4008 "acceptedAt": "2026-09-11T23:30:52.432Z"
40094009 },
40104010 {
40114011 "type": "VERSION_CONFLICT",
……@@ -4019,8 +4019,8 @@
40194019 "tree": "ee91b6bd77101e3404ca571b2e5adf0f47029d174398a05f50294fc207e39d99"
40204020 }
40214021 ],
4022 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4023 "acceptedAt": "2026-09-11T22:03:58.310Z"
4022 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4023 "acceptedAt": "2026-09-11T23:30:52.432Z"
40244024 },
40254025 {
40264026 "type": "VERSION_CONFLICT",
……@@ -4034,8 +4034,8 @@
40344034 "tree": "3bb78d8f06263a549bd1077f37a7df257b1d557e44608631435af625f05a9c11"
40354035 }
40364036 ],
4037 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4038 "acceptedAt": "2026-09-11T22:03:58.310Z"
4037 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4038 "acceptedAt": "2026-09-11T23:30:52.432Z"
40394039 },
40404040 {
40414041 "type": "VERSION_CONFLICT",
……@@ -4054,8 +4054,8 @@
40544054 "tree": "58c0bf8698af19464d0ee8354a77ed1ac96d3a5fa18aa4e794686cfbbf080e5d"
40554055 }
40564056 ],
4057 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4058 "acceptedAt": "2026-09-11T22:03:58.310Z"
4057 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4058 "acceptedAt": "2026-09-11T23:30:52.432Z"
40594059 },
40604060 {
40614061 "type": "VERSION_CONFLICT",
……@@ -4069,8 +4069,8 @@
40694069 "tree": "da4c6b2654e9d66f10b0faa989311cf182878c24ab51b1c41458cfbf37ad29cd"
40704070 }
40714071 ],
4072 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4073 "acceptedAt": "2026-09-11T22:03:58.310Z"
4072 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4073 "acceptedAt": "2026-09-11T23:30:52.432Z"
40744074 },
40754075 {
40764076 "type": "VERSION_CONFLICT",
……@@ -4084,8 +4084,8 @@
40844084 "tree": "40be8f559a13b54ebf0689e63e6c70f0d218035359e17c497ed7bc8a85c3cc95"
40854085 }
40864086 ],
4087 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4088 "acceptedAt": "2026-09-11T22:03:58.310Z"
4087 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4088 "acceptedAt": "2026-09-11T23:30:52.432Z"
40894089 },
40904090 {
40914091 "type": "VERSION_CONFLICT",
……@@ -4099,8 +4099,8 @@
40994099 "tree": "3eae85efce82299769898c8151b5ed0a94e5a5ebdbcdee2105b815ca733a61cb"
41004100 }
41014101 ],
4102 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4103 "acceptedAt": "2026-09-11T22:03:58.310Z"
4102 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4103 "acceptedAt": "2026-09-11T23:30:52.432Z"
41044104 },
41054105 {
41064106 "type": "VERSION_CONFLICT",
……@@ -4114,8 +4114,8 @@
41144114 "tree": "8d9e01d443a127781773598d2e77c3f372334fad09e20623a819dabe49b22e1a"
41154115 }
41164116 ],
4117 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4118 "acceptedAt": "2026-09-11T22:03:58.310Z"
4117 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4118 "acceptedAt": "2026-09-11T23:30:52.432Z"
41194119 },
41204120 {
41214121 "type": "VERSION_CONFLICT",
……@@ -4129,8 +4129,8 @@
41294129 "tree": "dc1ad6c999fe90d946ba0ef34698ea9af451b639be2ad1c5b61eabb08337a6ca"
41304130 }
41314131 ],
4132 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4133 "acceptedAt": "2026-09-11T22:03:58.310Z"
4132 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4133 "acceptedAt": "2026-09-11T23:30:52.432Z"
41344134 },
41354135 {
41364136 "type": "VERSION_CONFLICT",
……@@ -4144,8 +4144,8 @@
41444144 "tree": "88b4779570e5113aa21b204989ac018d1628b387681f7e53428a7c99114fb701"
41454145 }
41464146 ],
4147 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4148 "acceptedAt": "2026-09-11T22:03:58.310Z"
4147 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4148 "acceptedAt": "2026-09-11T23:30:52.432Z"
41494149 },
41504150 {
41514151 "type": "VERSION_CONFLICT",
……@@ -4159,8 +4159,8 @@
41594159 "tree": "720b867ec4300a3caa2325eb1449499f9940bfb88341ecceb5992fa0fdf5f37d"
41604160 }
41614161 ],
4162 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4163 "acceptedAt": "2026-09-11T22:03:58.310Z"
4162 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4163 "acceptedAt": "2026-09-11T23:30:52.432Z"
41644164 },
41654165 {
41664166 "type": "VERSION_CONFLICT",
……@@ -4174,8 +4174,8 @@
41744174 "tree": "b697fe174482a708b49d0764cd4786291845f0034a31e0699381ca7e7b269d04"
41754175 }
41764176 ],
4177 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4178 "acceptedAt": "2026-09-11T22:03:58.310Z"
4177 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4178 "acceptedAt": "2026-09-11T23:30:52.432Z"
41794179 },
41804180 {
41814181 "type": "VERSION_CONFLICT",
……@@ -4189,8 +4189,8 @@
41894189 "tree": "767f07242277ae80bd395de5a4969fcdbf956c216158ae517fb2b562558ac1a4"
41904190 }
41914191 ],
4192 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4193 "acceptedAt": "2026-09-11T22:03:58.310Z"
4192 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4193 "acceptedAt": "2026-09-11T23:30:52.432Z"
41944194 },
41954195 {
41964196 "type": "VERSION_CONFLICT",
……@@ -4204,8 +4204,8 @@
42044204 "tree": "de7deed21f587ffc8bde4971abe00fca77bb6c71c4e7effb31ad34ee330815df"
42054205 }
42064206 ],
4207 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4208 "acceptedAt": "2026-09-11T22:03:58.310Z"
4207 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4208 "acceptedAt": "2026-09-11T23:30:52.432Z"
42094209 },
42104210 {
42114211 "type": "VERSION_CONFLICT",
……@@ -4219,8 +4219,8 @@
42194219 "tree": "2df42517ebf73cd9c11f2f652f75e87401fd4ed7b856c0adc15c33baf1d4f1ad"
42204220 }
42214221 ],
4222 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4223 "acceptedAt": "2026-09-11T22:03:58.310Z"
4222 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4223 "acceptedAt": "2026-09-11T23:30:52.432Z"
42244224 },
42254225 {
42264226 "type": "VERSION_CONFLICT",
……@@ -4234,8 +4234,8 @@
42344234 "tree": "325aca171cbff560487177584078c8066133e91176639ca47c98d70e195aa92e"
42354235 }
42364236 ],
4237 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4238 "acceptedAt": "2026-09-11T22:03:58.310Z"
4237 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4238 "acceptedAt": "2026-09-11T23:30:52.432Z"
42394239 },
42404240 {
42414241 "type": "VERSION_CONFLICT",
……@@ -4264,8 +4264,8 @@
42644264 "tree": "89a40400106bf4fef95fb5c44a66ee29aada6f76c7740bfba09d8aeeb841f39c"
42654265 }
42664266 ],
4267 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4268 "acceptedAt": "2026-09-11T22:03:58.310Z"
4267 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4268 "acceptedAt": "2026-09-11T23:30:52.432Z"
42694269 },
42704270 {
42714271 "type": "VERSION_CONFLICT",
……@@ -4279,8 +4279,8 @@
42794279 "tree": "1bd0eb382789c62cd4622d47bffb60984fdd766260c79f73a2fc7a136f3edbe9"
42804280 }
42814281 ],
4282 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4283 "acceptedAt": "2026-09-11T22:03:58.310Z"
4282 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4283 "acceptedAt": "2026-09-11T23:30:52.432Z"
42844284 },
42854285 {
42864286 "type": "VERSION_CONFLICT",
……@@ -4294,8 +4294,8 @@
42944294 "tree": "4526fe9dc5468264c7171ec16e9303726674b7b44ca0b3e09cb8b9a6a1be6169"
42954295 }
42964296 ],
4297 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4298 "acceptedAt": "2026-09-11T22:03:58.310Z"
4297 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4298 "acceptedAt": "2026-09-11T23:30:52.432Z"
42994299 },
43004300 {
43014301 "type": "VERSION_CONFLICT",
……@@ -4309,8 +4309,8 @@
43094309 "tree": "32b291cd756e142da18a6ed01c351e024fcedc3e2b1ecfbfb85f3106ada4419b"
43104310 }
43114311 ],
4312 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4313 "acceptedAt": "2026-09-11T22:03:58.310Z"
4312 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4313 "acceptedAt": "2026-09-11T23:30:52.432Z"
43144314 },
43154315 {
43164316 "type": "VERSION_CONFLICT",
……@@ -4324,8 +4324,8 @@
43244324 "tree": "4a8cd3b0093092b33088617dc0415e8179882d7f2f13b7649962ca20b08beeed"
43254325 }
43264326 ],
4327 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4328 "acceptedAt": "2026-09-11T22:03:58.310Z"
4327 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4328 "acceptedAt": "2026-09-11T23:30:52.432Z"
43294329 },
43304330 {
43314331 "type": "VERSION_CONFLICT",
……@@ -4339,8 +4339,8 @@
43394339 "tree": "4f63f165ee6a581f689b40c4a40df4df01ab6fba17d46bd29e40ef5bbdd9884f"
43404340 }
43414341 ],
4342 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4343 "acceptedAt": "2026-09-11T22:03:58.310Z"
4342 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4343 "acceptedAt": "2026-09-11T23:30:52.432Z"
43444344 },
43454345 {
43464346 "type": "VERSION_CONFLICT",
……@@ -4354,8 +4354,8 @@
43544354 "tree": "735109c736c0bd3413ab47414fd8b6ea9a99c401fe8e6d608459c79293806754"
43554355 }
43564356 ],
4357 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4358 "acceptedAt": "2026-09-11T22:03:58.310Z"
4357 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4358 "acceptedAt": "2026-09-11T23:30:52.432Z"
43594359 },
43604360 {
43614361 "type": "VERSION_CONFLICT",
……@@ -4369,8 +4369,8 @@
43694369 "tree": "b32b75bf1b2acdd18ed0cc370d01d477fba42ba09fd1f2f78af3ebad925e72d5"
43704370 }
43714371 ],
4372 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4373 "acceptedAt": "2026-09-11T22:03:58.310Z"
4372 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4373 "acceptedAt": "2026-09-11T23:30:52.432Z"
43744374 },
43754375 {
43764376 "type": "VERSION_CONFLICT",
……@@ -4389,8 +4389,8 @@
43894389 "tree": "189397178121e9a18430943345dab820691648a324229071ceb980bbb77a7393"
43904390 }
43914391 ],
4392 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4393 "acceptedAt": "2026-09-11T22:03:58.310Z"
4392 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4393 "acceptedAt": "2026-09-11T23:30:52.432Z"
43944394 },
43954395 {
43964396 "type": "VERSION_CONFLICT",
……@@ -4404,8 +4404,8 @@
44044404 "tree": "3bedca5287a9041fc2fd5ec5ace4df1fa86240e6afec0a105ba67398bafc09a5"
44054405 }
44064406 ],
4407 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4408 "acceptedAt": "2026-09-11T22:03:58.310Z"
4407 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4408 "acceptedAt": "2026-09-11T23:30:52.432Z"
44094409 },
44104410 {
44114411 "type": "VERSION_CONFLICT",
……@@ -4419,8 +4419,8 @@
44194419 "tree": "b9ca9b272008bd67672a1b252390e8b527095c46b46cf6b6725a7a187eb038b6"
44204420 }
44214421 ],
4422 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4423 "acceptedAt": "2026-09-11T22:03:58.310Z"
4422 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4423 "acceptedAt": "2026-09-11T23:30:52.432Z"
44244424 },
44254425 {
44264426 "type": "VERSION_CONFLICT",
……@@ -4434,8 +4434,8 @@
44344434 "tree": "7793645d7d4248d6d6533e3cbcd4bf40bed4f38c678b0ca0606c15640ba5e7b9"
44354435 }
44364436 ],
4437 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4438 "acceptedAt": "2026-09-11T22:03:58.310Z"
4437 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4438 "acceptedAt": "2026-09-11T23:30:52.432Z"
44394439 },
44404440 {
44414441 "type": "VERSION_CONFLICT",
……@@ -4449,8 +4449,8 @@
44494449 "tree": "79447513d4ac0357c8d60bdc7b81b3c5143d761b26700340cba266c4d1cb77b0"
44504450 }
44514451 ],
4452 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4453 "acceptedAt": "2026-09-11T22:03:58.310Z"
4452 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4453 "acceptedAt": "2026-09-11T23:30:52.432Z"
44544454 },
44554455 {
44564456 "type": "VERSION_CONFLICT",
……@@ -4464,8 +4464,8 @@
44644464 "tree": "279d4e0447eb79fd75816864ba7f41f2df03080c9da69ea4603fe2900c8d9e53"
44654465 }
44664466 ],
4467 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4468 "acceptedAt": "2026-09-11T22:03:58.310Z"
4467 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4468 "acceptedAt": "2026-09-11T23:30:52.432Z"
44694469 },
44704470 {
44714471 "type": "VERSION_CONFLICT",
……@@ -4479,8 +4479,8 @@
44794479 "tree": "ed4e0b480417610e195ff0b59e5b9a453abbf2db6d6ce72d7cd522269f835e9a"
44804480 }
44814481 ],
4482 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4483 "acceptedAt": "2026-09-11T22:03:58.310Z"
4482 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4483 "acceptedAt": "2026-09-11T23:30:52.432Z"
44844484 },
44854485 {
44864486 "type": "VERSION_CONFLICT",
……@@ -4499,8 +4499,8 @@
44994499 "tree": "a9e07d54e526c838dfe5e27df33936682aefb218f61a45b6c15289243999f648"
45004500 }
45014501 ],
4502 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4503 "acceptedAt": "2026-09-11T22:03:58.310Z"
4502 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4503 "acceptedAt": "2026-09-11T23:30:52.432Z"
45044504 },
45054505 {
45064506 "type": "VERSION_CONFLICT",
……@@ -4514,8 +4514,8 @@
45144514 "tree": "9d84c86441fe138b227c4fd01d1e948aa83bf8d13cfb1aa957838feea05fbb57"
45154515 }
45164516 ],
4517 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4518 "acceptedAt": "2026-09-11T22:03:58.310Z"
4517 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4518 "acceptedAt": "2026-09-11T23:30:52.432Z"
45194519 },
45204520 {
45214521 "type": "VERSION_CONFLICT",
……@@ -4529,8 +4529,8 @@
45294529 "tree": "4a3afbe2fc7a882e24454eb2e7e7cfe6816811ee5d73ae0f92e8b9ea9cfb8c78"
45304530 }
45314531 ],
4532 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4533 "acceptedAt": "2026-09-11T22:03:58.310Z"
4532 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4533 "acceptedAt": "2026-09-11T23:30:52.432Z"
45344534 },
45354535 {
45364536 "type": "VERSION_CONFLICT",
……@@ -4544,8 +4544,8 @@
45444544 "tree": "1e5c5810d8971a3c27fde7fb0e4d633dcabbe2cf98d8df86b473f8d67eead8b9"
45454545 }
45464546 ],
4547 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4548 "acceptedAt": "2026-09-11T22:03:58.310Z"
4547 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4548 "acceptedAt": "2026-09-11T23:30:52.432Z"
45494549 },
45504550 {
45514551 "type": "VERSION_CONFLICT",
……@@ -4559,8 +4559,8 @@
45594559 "tree": "e3478f50e85ba0ac288efed83e84c992483904897dc39ccf243d20a4ea25d972"
45604560 }
45614561 ],
4562 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4563 "acceptedAt": "2026-09-11T22:03:58.310Z"
4562 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4563 "acceptedAt": "2026-09-11T23:30:52.432Z"
45644564 },
45654565 {
45664566 "type": "VERSION_CONFLICT",
……@@ -4574,8 +4574,8 @@
45744574 "tree": "c05d8bf7eab5024ea98140f78c84d12fb494b4b7b10c7eedeb50b2be842fa519"
45754575 }
45764576 ],
4577 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4578 "acceptedAt": "2026-09-11T22:03:58.310Z"
4577 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4578 "acceptedAt": "2026-09-11T23:30:52.432Z"
45794579 },
45804580 {
45814581 "type": "VERSION_CONFLICT",
……@@ -4589,8 +4589,8 @@
45894589 "tree": "0697bb338bbcec517353cffcdb2345a8232b9b90cc627a549a306f0323576390"
45904590 }
45914591 ],
4592 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4593 "acceptedAt": "2026-09-11T22:03:58.310Z"
4592 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4593 "acceptedAt": "2026-09-11T23:30:52.432Z"
45944594 },
45954595 {
45964596 "type": "VERSION_CONFLICT",
……@@ -4604,8 +4604,8 @@
46044604 "tree": "a366a73e74f8d80f106465058083b62bb70b833c721047124e99c89880e5c706"
46054605 }
46064606 ],
4607 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4608 "acceptedAt": "2026-09-11T22:03:58.310Z"
4607 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4608 "acceptedAt": "2026-09-11T23:30:52.432Z"
46094609 },
46104610 {
46114611 "type": "VERSION_CONFLICT",
……@@ -4619,8 +4619,8 @@
46194619 "tree": "7912e790c9e87e9829a6daa037c5f5da0cc4355214f51d334f96e57678dfddeb"
46204620 }
46214621 ],
4622 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4623 "acceptedAt": "2026-09-11T22:03:58.310Z"
4622 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4623 "acceptedAt": "2026-09-11T23:30:52.432Z"
46244624 },
46254625 {
46264626 "type": "VERSION_CONFLICT",
……@@ -4634,8 +4634,8 @@
46344634 "tree": "003ac841449fcdda1abc3c87fee291a5d9596d245925a7298bd8ef5f8e423eae"
46354635 }
46364636 ],
4637 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4638 "acceptedAt": "2026-09-11T22:03:58.310Z"
4637 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4638 "acceptedAt": "2026-09-11T23:30:52.432Z"
46394639 },
46404640 {
46414641 "type": "VERSION_CONFLICT",
……@@ -4654,8 +4654,8 @@
46544654 "tree": "7b3f314cf92558edbc90295c573885276a6add925318998c829c03d335edc765"
46554655 }
46564656 ],
4657 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4658 "acceptedAt": "2026-09-11T22:03:58.310Z"
4657 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4658 "acceptedAt": "2026-09-11T23:30:52.432Z"
46594659 },
46604660 {
46614661 "type": "VERSION_CONFLICT",
……@@ -4669,8 +4669,8 @@
46694669 "tree": "c486fe73805bf013e9c5de91b5780d1aa5d2b2295306f411f7863757aaa05e60"
46704670 }
46714671 ],
4672 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4673 "acceptedAt": "2026-09-11T22:03:58.310Z"
4672 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4673 "acceptedAt": "2026-09-11T23:30:52.432Z"
46744674 },
46754675 {
46764676 "type": "VERSION_CONFLICT",
……@@ -4684,8 +4684,8 @@
46844684 "tree": "10193a86e2c6f80c2d4377379a0aae4a959f8a40fd9e852b85fee081bfe6675a"
46854685 }
46864686 ],
4687 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4688 "acceptedAt": "2026-09-11T22:03:58.310Z"
4687 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4688 "acceptedAt": "2026-09-11T23:30:52.432Z"
46894689 },
46904690 {
46914691 "type": "VERSION_CONFLICT",
……@@ -4699,8 +4699,8 @@
46994699 "tree": "20b1ecd2937f3e35102892288234b86a8ec6deb3711016d96ff3509474ac465b"
47004700 }
47014701 ],
4702 "reason": "ap-bridge 0.9.91 — holder re-claim is admitted; a pending claim names the acct",
4703 "acceptedAt": "2026-09-11T22:03:58.310Z"
4702 "reason": "ap-bridge 0.9.93 — admin surface enabled on the deployment",
4703 "acceptedAt": "2026-09-11T23:30:52.432Z"
47044704 }
47054705 ]
47064706}
index.js +29 -4
……@@ -10,6 +10,7 @@ import fs from 'bare-fs'
1010import process from 'bare-process'
1111import { join } from 'bare-path'
1212import { loadConfig, assertUsable } from './lib/config.js'
13import crypto from 'bare-crypto'
1314import http from 'bare-http1'
1415import { createServer } from './lib/server.js'
1516import { actorDocFor } from './lib/router.js'
……@@ -490,11 +491,30 @@ async function main () {
490491 // it must be a decision, never a default." It mints nothing; every action on it is a flow that
491492 // already exists (invite, list, release).
492493 if (config.adminPort) {
493 const { adminRoutes } = await import('./lib/admin.js')
494 const { serveAdmin } = await import('./lib/admin-surface.js')
495 const { Sessions } = await import('./lib/admin-auth.js')
494496 const { parseForm } = await import('./lib/admin-serve.js')
497 const { Browsers } = await import('favela-peer/lib/browsers.js')
498
499 // ⛔ A TOKEN PER BOOT, HELD ONLY IN MEMORY AND ONLY UNTIL CLAIMED. Writing it to disk would make a
500 // standing secret out of something needed exactly once; regenerating it every start costs nothing
501 // because it is meaningless the moment the bridge is claimed.
502 const bootstrap = crypto.randomBytes(16).toString('hex')
503 // GOTCHA, same as the record store above: openCorestore, never `new Corestore` from a separately
504 // installed copy — `instanceof Corestore` fails across duplicated module copies.
505 const browsers = new Browsers(openCorestore(join(dataDir, 'admin-identity')), { bootstrap })
506 await browsers.ready()
507
508 // ⛔ PRINTED ONLY WHILE UNCLAIMED, because after that it grants nothing and logging a dead secret
509 // every boot teaches an operator to ignore the line that matters.
510 if (await browsers.unclaimed()) {
511 log({ msg: 'admin surface UNCLAIMED — claim it with this bootstrap token, then it closes for good', bootstrap })
512 }
513
514 const adminCtx = { browsers, sessions: new Sessions(), registrations, inbound, config, root: new URL('.', import.meta.url).pathname }
495515 const adminServer = http.createServer((req, res) => {
496 parseForm(req).then((form) => adminRoutes({ method: req.method, path: (req.url || '/').split('?')[0], form },
497 { registrations, inbound, config }))
516 parseForm(req).then((form) => serveAdmin(
517 { method: req.method, path: (req.url || '/').split('?')[0], form, headers: req.headers || {} }, adminCtx))
498518 .then((out) => { res.writeHead(out.status, out.headers); res.end(out.body) })
499519 .catch((err) => { res.writeHead(500, { 'content-type': 'text/plain' }); res.end(String((err && err.message) || err)) })
500520 })
……@@ -504,7 +524,12 @@ async function main () {
504524 // the public listener must not thereby publish the admin surface.
505525 adminServer.listen(config.adminPort, '127.0.0.1', resolve)
506526 })
507 log({ msg: 'admin surface (loopback only, no auth — reach it over SSH)', url: 'http://127.0.0.1:' + config.adminPort + '/admin' })
527 // ⛔ STILL LOOPBACK EVEN THOUGH IT NOW HAS AUTH, and for a reason that is not caution:
528 // static/anproto-client.js hashes with `crypto.subtle`, which browsers expose only in a SECURE
529 // CONTEXT ("works on localhost + HTTPS" — its own comment). Bound to a public port over plain
530 // HTTP this would serve a login page whose crypto is undefined in the browser that loaded it.
531 // Publish it through the proxy that already terminates TLS for this host.
532 log({ msg: 'admin surface (authenticated; loopback — publish it over HTTPS via the proxy)', url: 'http://127.0.0.1:' + config.adminPort + '/admin' })
508533 }
509534
510535 // ★ The first pass picks up whatever the last run left owed — which is the entire point of the
lib/admin-auth.js +185 -0
……@@ -0,0 +1,185 @@
1// admin-auth.js — who may operate this bridge.
2//
3// ⛔ THE SAME IDENTITY LAYER THE REST OF THE STACK USES, not a password. `Browsers` (favela-peer)
4// holds the registry: an ANProto keypair proves itself against a single-use challenge, the FIRST one
5// claims the instance, and every one after needs an invite. A bearer password would have been fewer
6// moving parts and a seventh way to authenticate in one monorepo.
7//
8// ⛔ AND THE FIRST CLAIM IS GATED BY A BOOTSTRAP TOKEN. Plain first-claim TOFU is safe only while the
9// surface is bound to loopback — browsers.js says so in its own header — and an operator page has to be
10// reachable. The token is printed to the service log at startup, so it is a secret only whoever can
11// read the log holds. See browsers.js for the gate and the claim latch.
12//
13// ⛔ HTTPS OR LOCALHOST, NOT PLAIN HTTP ON A PUBLIC PORT. static/anproto-client.js hashes with
14// `crypto.subtle`, which browsers expose only in a secure context — its own comment says "works on
15// localhost + HTTPS". So this listener stays on loopback and is published through the proxy that
16// already terminates TLS for this host. Binding it to the network directly would serve a login page
17// whose crypto is undefined in the browser that loaded it.
18
19import crypto from 'bare-crypto'
20
21export const COOKIE = 'ap_admin'
22
23/**
24 * Cookie -> the browser public key that proved itself. That is ALL it is.
25 *
26 * ⛔ EVERY REQUEST RE-ASKS whether that key is still allowed, so a revoke lands on the next request
27 * rather than whenever something lapses. That is why there is no expiry to configure: the check is
28 * live, and an expiry would only add a way to be logged out for no reason. Same shape favela-surface
29 * uses, and for the same reasons.
30 *
31 * ⚠ In memory, so a restart means logging in again. Persisting them would make a bridge own durable
32 * security state it has no other reason to hold.
33 */
34export class Sessions {
35 constructor () { this._byId = new Map() }
36
37 create (publicKey) {
38 const id = crypto.randomBytes(32).toString('hex')
39 this._byId.set(id, publicKey)
40 return id
41 }
42
43 get (id) { return (id && this._byId.get(id)) || null }
44 destroy (id) { if (id) this._byId.delete(id) }
45 get size () { return this._byId.size }
46}
47
48export function parseCookies (header) {
49 const out = {}
50 for (const part of String(header || '').split(';')) {
51 const i = part.indexOf('=')
52 if (i === -1) continue
53 out[part.slice(0, i).trim()] = part.slice(i + 1).trim()
54 }
55 return out
56}
57
58/**
59 * The public key on this request, if it is still allowed to operate the bridge.
60 *
61 * ⛔ `browsers.status()` IS THE AUTHORITY, not the cookie. A cookie that maps to a revoked key must
62 * stop working immediately — holding a session must never outlive the grant behind it.
63 */
64export async function currentAdmin (req, { sessions, browsers }) {
65 const id = parseCookies(req.headers && req.headers.cookie)[COOKIE]
66 const publicKey = sessions.get(id)
67 if (!publicKey) return null
68 try {
69 const st = await browsers.status(publicKey)
70 if (!st || !st.known || st.revoked) { sessions.destroy(id); return null }
71 } catch { return null }
72 return publicKey
73}
74
75const esc = (s) => String(s == null ? '' : s)
76 .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
77
78export function loginPage ({ unclaimed = false, message = null } = {}) {
79 const warn = message ? '<p class="warn">' + esc(message) + '</p>' : ''
80 const intro = unclaimed
81 ? '<p>Nobody operates this bridge yet. Claim it with the <strong>bootstrap token</strong> printed ' +
82 'in the service log when it started.</p>' +
83 '<p><label>Bootstrap token<br><input id="bootstrap" type="password" autocomplete="off" ' +
84 'placeholder="32 hex characters"></label></p>'
85 : '<p>This browser signs in with the key it generated the first time you used it. If this is a new ' +
86 'browser it needs an invite from one that is already signed in.</p>'
87
88 return `<!doctype html><meta charset="utf-8"><title>bridge · sign in</title>
89<style>body{font:14px/1.5 system-ui,sans-serif;margin:0;padding:40px;max-width:520px;color:#111}
90h1{font-size:20px}.warn{color:#a00}.who{color:#666}input{font:inherit;padding:5px;width:100%;box-sizing:border-box}
91button{font:inherit;padding:6px 14px;margin-top:12px}code{font:12px ui-monospace,monospace;background:#f5f5f5;padding:1px 4px}</style>
92<h1>Sign in to this bridge</h1>${warn}${intro}
93<button id="go">sign in</button>
94<p class="who" id="msg"></p>
95<p class="who">Your key: <code id="pk">…</code></p>
96<script type="module">
97import { ANProtoClient as an } from '/static/anproto-client.js'
98
99// ⛔ THE KEY LIVES IN THIS BROWSER AND NOWHERE ELSE. It is not a device key and never enters the
100// attestation chain — a browser cannot hold a writer core or verify a chain. It authenticates; the
101// daemon writes.
102const KEY = 'ap-admin-keypair'
103let kp = localStorage.getItem(KEY)
104if (!kp) { kp = await an.gen(); localStorage.setItem(KEY, kp) }
105const pk = an.getPublicKey(kp)
106document.getElementById('pk').textContent = pk
107
108const msg = document.getElementById('msg')
109const post = (path, body) => fetch(path, {
110 method: 'POST',
111 headers: { 'content-type': 'application/x-www-form-urlencoded' },
112 body: new URLSearchParams(body).toString()
113})
114
115document.getElementById('go').onclick = async () => {
116 msg.textContent = 'signing…'
117 try {
118 const r = await post('/admin/challenge', {})
119 const { challenge } = await r.json()
120 // ⚠ The raw challenge — this client hashes it internally and emits the canonical wire format.
121 // Hashing it here first would sign the wrong thing, verify, and then fail the hash check.
122 const signature = await an.sign(challenge, kp)
123 const el = document.getElementById('bootstrap')
124 const out = await post('/admin/session', {
125 publicKey: pk, signature, challenge, bootstrap: el ? el.value : ''
126 })
127 if (out.ok) { location.href = '/admin'; return }
128 msg.textContent = await out.text()
129 } catch (e) {
130 msg.textContent = (e && e.message) || String(e)
131 }
132}
133</script>`
134}
135
136const text = (status, body) => ({ status, headers: { 'content-type': 'text/plain; charset=utf-8' }, body })
137const html = (status, body) => ({ status, headers: { 'content-type': 'text/html; charset=utf-8' }, body })
138
139/**
140 * The sign-in routes. Returns null when the path is not one of ours, so the caller can route on.
141 */
142export async function authRoutes (req, { browsers, sessions }) {
143 const path = req.path || '/'
144
145 if (req.method === 'GET' && path === '/admin/login') {
146 return html(200, loginPage({ unclaimed: await browsers.unclaimed() }))
147 }
148
149 if (req.method === 'POST' && path === '/admin/challenge') {
150 const { challenge } = browsers.challenge()
151 return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ challenge }) }
152 }
153
154 if (req.method === 'POST' && path === '/admin/session') {
155 const f = req.form || {}
156 const proof = { publicKey: f.publicKey, signature: f.signature, challenge: f.challenge }
157 try {
158 // ⛔ CLAIM OR AUTHENTICATE — never both, and the instance decides which. Offering a "claim"
159 // button after the first one would be a second door onto an instance that is already owned.
160 if (await browsers.unclaimed()) await browsers.claim({ ...proof, bootstrap: f.bootstrap || null })
161 else await browsers.authenticate(proof)
162 } catch (err) {
163 // ⚠ The daemon's own words: it knows whether the token was wrong, the key unknown, the
164 // challenge expired, or the instance already claimed. A generic "sign-in failed" would make
165 // every one of those look like a typo.
166 return text(403, (err && err.message) || String(err))
167 }
168 const id = sessions.create(f.publicKey)
169 return {
170 status: 200,
171 // ⚠ HttpOnly so a page cannot read it; SameSite=Strict so it never rides a cross-site request.
172 headers: { 'set-cookie': COOKIE + '=' + id + '; HttpOnly; SameSite=Strict; Path=/' },
173 body: 'ok'
174 }
175 }
176
177 if (req.method === 'POST' && path === '/admin/logout') {
178 sessions.destroy(parseCookies(req.headers && req.headers.cookie)[COOKIE])
179 return { status: 303, headers: { location: '/admin/login', 'set-cookie': COOKIE + '=; Max-Age=0; Path=/' }, body: '' }
180 }
181
182 return null
183}
184
185export default { Sessions, authRoutes, currentAdmin, loginPage, parseCookies, COOKIE }
lib/admin-surface.js +51 -0
……@@ -0,0 +1,51 @@
1// The operator surface, assembled: sign-in, then the pages, with the gate between them.
2//
3// ⛔ THE GATE IS HERE AND NOWHERE ELSE, so there is one place to read to know what is protected. Every
4// path that is not sign-in or a static asset requires a live session, and "live" is re-checked against
5// the identity registry per request rather than trusted from the cookie.
6//
7// ⛔ STATIC IS SERVED FROM A FIXED LIST, not from a directory join. A path taken off the wire and
8// joined to a root is a traversal bug; three files that never change do not need a filesystem walk.
9
10import fs from 'bare-fs'
11import { join } from 'bare-path'
12import { authRoutes, currentAdmin } from './admin-auth.js'
13import { adminRoutes } from './admin.js'
14
15const TYPES = { '.js': 'text/javascript; charset=utf-8' }
16
17// The browser half of the identity layer. Vendored, and the same files favela-surface and pear-git
18// serve — noble is pure JS so signing works without crypto.subtle; the SHA-256 in the client does use
19// it, which is why this surface must be reached over HTTPS or localhost.
20const ASSETS = new Set(['anproto-client.js', 'noble-ed25519.js', 'noble-sha512.js'])
21
22export function staticAsset (name, root) {
23 if (!ASSETS.has(name)) return null
24 try {
25 return { status: 200, headers: { 'content-type': TYPES['.js'] }, body: fs.readFileSync(join(root, 'static', name)) }
26 } catch {
27 return null
28 }
29}
30
31export async function serveAdmin (req, ctx) {
32 const path = req.path || '/'
33
34 if (path.startsWith('/static/')) {
35 const asset = staticAsset(path.slice('/static/'.length), ctx.root)
36 return asset || { status: 404, headers: {}, body: 'not found' }
37 }
38
39 // Sign-in first: these are the only routes reachable without a session, by definition.
40 const auth = await authRoutes(req, ctx)
41 if (auth) return auth
42
43 // ⛔ NO SESSION, NO SURFACE. A redirect rather than a 403 because the operator's next act is always
44 // to sign in, and a bare 403 on a page they navigated to reads as "this is broken".
45 const admin = await currentAdmin(req, ctx)
46 if (!admin) return { status: 303, headers: { location: '/admin/login' }, body: '' }
47
48 return adminRoutes(req, ctx)
49}
50
51export default { serveAdmin, staticAsset }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "ap-bridge",
3 "version": "0.9.93",
3 "version": "0.9.94",
44 "description": "ActivityPub membrane — a second projection of the self-custody atproto peer",
55 "type": "module",
66 "main": "index.js",
static/anproto-client.js +161 -0
……@@ -0,0 +1,161 @@
1/**
2 * ANProto Browser Client — Ed25519 identity for browser contexts
3 *
4 * Uses vendored @noble/ed25519 (pure JS, no native deps, works on HTTP).
5 * Same protocol as server-side ANProto — keypairs, challenge-response signing.
6 *
7 * Keypair format: 132-char base64 string (44 pubkey + 88 secretkey)
8 * Signature format: 88-char base64 (64 bytes)
9 */
10
11import * as ed from './noble-ed25519.js'
12import { sha512 } from './noble-sha512.js'
13
14// Wire pure-JS SHA-512 into noble-ed25519 (replaces crypto.subtle dependency)
15ed.etc.sha512Sync = (...msgs) => {
16 const h = sha512.create()
17 for (const m of msgs) h.update(m)
18 return h.digest()
19}
20ed.etc.sha512Async = async (...msgs) => ed.etc.sha512Sync(...msgs)
21
22export const ANProtoClient = {
23 /**
24 * Generate a new Ed25519 keypair
25 * @returns {Promise<string>} 132-char base64 keypair (44 pub + 88 secret)
26 */
27 async gen () {
28 const privateKey = ed.utils.randomPrivateKey()
29 const publicKey = await ed.getPublicKeyAsync(privateKey)
30
31 // Build 64-byte secret key (32 seed + 32 pubkey) to match sodium format
32 const secretKey = new Uint8Array(64)
33 secretKey.set(privateKey, 0)
34 secretKey.set(publicKey, 32)
35
36 return bytesToBase64(publicKey) + bytesToBase64(secretKey)
37 },
38
39 /**
40 * Get public key from keypair string
41 * @param {string} keypair — 132-char base64
42 * @returns {string} 44-char base64 public key
43 */
44 getPublicKey (keypair) {
45 return keypair.substring(0, 44)
46 },
47
48 /**
49 * Get public key as hex string
50 * @param {string} keypair — 132-char base64
51 * @returns {string} 64-char hex public key
52 */
53 getPublicKeyHex (keypair) {
54 const bytes = base64ToBytes(keypair.substring(0, 44))
55 return bytesToHex(bytes)
56 },
57
58 /**
59 * SHA-256 hash (uses crypto.subtle, works on localhost + HTTPS)
60 * @param {string} data
61 * @returns {Promise<string>} 44-char base64 hash
62 */
63 async hash (data) {
64 const bytes = new TextEncoder().encode(data)
65 const hashBuf = await crypto.subtle.digest('SHA-256', bytes)
66 return bytesToBase64(new Uint8Array(hashBuf))
67 },
68
69 /**
70 * Sign a challenge using canonical ANProto wire format.
71 *
72 * Produces: pubkey(44 base64) + base64(signature(64) || timestamp + hash)
73 * Wire-compatible with server-side ANProto.verify() and an.open().
74 *
75 * @param {string} challenge — challenge string (from server)
76 * @param {string} keypair — 132-char base64 keypair
77 * @returns {Promise<string>} canonical wire-format signature
78 */
79 async sign (challenge, keypair) {
80 const pubKeyB64 = keypair.substring(0, 44)
81 const secretKeyB64 = keypair.substring(44, 132)
82 const seed = base64ToBytes(secretKeyB64).slice(0, 32)
83
84 // Canonical: hash the challenge, prepend timestamp, sign the combined message
85 const contentHash = await this.hash(challenge)
86 const ts = Date.now().toString()
87 const message = ts + contentHash
88 const messageBytes = new TextEncoder().encode(message)
89
90 // Detached signature of the message
91 const sig = await ed.signAsync(messageBytes, seed)
92
93 // Combined-mode envelope: signature(64 bytes) || message
94 const combined = new Uint8Array(sig.length + messageBytes.length)
95 combined.set(sig, 0)
96 combined.set(messageBytes, sig.length)
97
98 return pubKeyB64 + bytesToBase64(combined)
99 },
100
101 /**
102 * Verify a canonical ANProto signature and extract the signed data.
103 * @param {string} message — wire-format signature
104 * @returns {Promise<{ valid: boolean, publicKey: string, timestamp: number, hash: string }>}
105 */
106 async open (message) {
107 try {
108 if (typeof message !== 'string' || message.length < 44) {
109 return { valid: false, publicKey: null, timestamp: null, hash: null }
110 }
111 const publicKeyB64 = message.substring(0, 44)
112 const publicKey = base64ToBytes(publicKeyB64)
113 const combined = base64ToBytes(message.substring(44))
114
115 if (combined.length <= 64) {
116 return { valid: false, publicKey: publicKeyB64, timestamp: null, hash: null }
117 }
118
119 const sig = combined.slice(0, 64)
120 const body = combined.slice(64)
121
122 const valid = await ed.verifyAsync(sig, body, publicKey)
123 if (!valid) {
124 return { valid: false, publicKey: publicKeyB64, timestamp: null, hash: null }
125 }
126
127 const bodyStr = new TextDecoder().decode(body)
128 // ⛔ 13 digits exactly — see lib/anproto.js. A greedy match eats the hash's leading character
129 // whenever it is a digit, which is ~1 in 6 messages.
130 const tsMatch = bodyStr.match(/^(\d{13})/)
131 const timestamp = tsMatch ? parseInt(tsMatch[0], 10) : null
132 const hash = tsMatch ? bodyStr.substring(tsMatch[0].length) : bodyStr
133
134 return { valid: true, publicKey: publicKeyB64, timestamp, hash }
135 } catch {
136 return { valid: false, publicKey: null, timestamp: null, hash: null }
137 }
138 }
139}
140
141// ── Encoding helpers ─────────────────────────────────────────
142
143function base64ToBytes (base64) {
144 const binary = atob(base64)
145 const bytes = new Uint8Array(binary.length)
146 for (let i = 0; i < binary.length; i++) {
147 bytes[i] = binary.charCodeAt(i)
148 }
149 return bytes
150}
151
152function bytesToBase64 (bytes) {
153 const binary = String.fromCharCode(...bytes)
154 return btoa(binary)
155}
156
157function bytesToHex (bytes) {
158 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
159}
160
161export default ANProtoClient
static/noble-ed25519.js +9 -0
……@@ -0,0 +1,9 @@
1/**
2 * Bundled by jsDelivr using Rollup v2.79.2 and Terser v5.39.0.
3 * Original file: /npm/@noble/ed25519@2.0.0/index.js
4 *
5 * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
6 */
7/*! noble-ed25519 - MIT License (c) 2019 Paul Miller (paulmillr.com) */
8const t=2n**255n-19n,e=2n**252n+27742317777372353535851937790883648493n,n=0x216936d3cd6e53fec0a4e231fdd6dc5c692cc7609525a7b2c9562d608f25d51an,r=0x6666666666666666666666666666666666666666666666666666666666666658n,s={a:-1n,d:37095705934669439343138083508754565189542113879843219016388785533085940283555n,p:t,n:e,h:8,Gx:n,Gy:r},o=(t="")=>{throw new Error(t)},i=t=>"string"==typeof t,a=(t,e)=>!(t instanceof Uint8Array)||"number"==typeof e&&e>0&&t.length!==e?o("Uint8Array expected"):t,c=t=>new Uint8Array(t),l=(t,e)=>a(i(t)?m(t):c(t),e),u=(e,n=t)=>{let r=e%n;return r>=0n?r:n+r},d=t=>t instanceof y?t:o("Point expected");let h;class y{constructor(t,e,n,r){this.ex=t,this.ey=e,this.ez=n,this.et=r}static fromAffine(t){return new y(t.x,t.y,1n,u(t.x*t.y))}static fromHex(e,n=!0){const{d:r}=s,i=(e=l(e,32)).slice();i[31]=-129&e[31];const a=w(i);0n===a||(!n||0n<a&&a<t||o("bad y coord 1"),n||0n<a&&a<2n**256n||o("bad y coord 2"));const c=u(a*a),d=u(c-1n),h=u(r*c+1n);let{isValid:f,value:p}=E(d,h);f||o("bad y coordinate 3");const x=1n===(1n&p);return!!(128&e[31])!==x&&(p=u(-p)),new y(p,a,1n,u(p*a))}get x(){return this.toAffine().x}get y(){return this.toAffine().y}equals(t){const{ex:e,ey:n,ez:r}=this,{ex:s,ey:o,ez:i}=d(t),a=u(e*i),c=u(s*r),l=u(n*i),h=u(o*r);return a===c&&l===h}is0(){return this.equals(p)}negate(){return new y(u(-this.ex),this.ey,this.ez,u(-this.et))}double(){const{ex:t,ey:e,ez:n}=this,{a:r}=s,o=u(t*t),i=u(e*e),a=u(2n*u(n*n)),c=u(r*o),l=t+e,d=u(u(l*l)-o-i),h=c+i,f=h-a,p=c-i,x=u(d*f),b=u(h*p),m=u(d*p),g=u(f*h);return new y(x,b,g,m)}add(t){const{ex:e,ey:n,ez:r,et:o}=this,{ex:i,ey:a,ez:c,et:l}=d(t),{a:h,d:f}=s,p=u(e*i),x=u(n*a),b=u(o*f*l),m=u(r*c),g=u((e+n)*(i+a)-p-x),w=u(m-b),B=u(m+b),A=u(x-h*p),v=u(g*w),R=u(B*A),E=u(g*A),S=u(w*B);return new y(v,R,S,E)}mul(t,n=!0){if(0n===t)return!0===n?o("cannot multiply by 0"):p;if("bigint"==typeof t&&0n<t&&t<e||o("invalid scalar, must be < L"),!n&&this.is0()||1n===t)return this;if(this.equals(f))return M(t).p;let r=p,s=f;for(let e=this;t>0n;e=e.double(),t>>=1n)1n&t?r=r.add(e):n&&(s=s.add(e));return r}multiply(t){return this.mul(t)}clearCofactor(){return this.mul(BigInt(s.h),!1)}isSmallOrder(){return this.clearCofactor().is0()}isTorsionFree(){let t=this.mul(e/2n,!1).double();return t=t.add(this),t.is0()}toAffine(){const{ex:t,ey:e,ez:n}=this;if(this.is0())return{x:0n,y:0n};const r=A(n);return 1n!==u(n*r)&&o("invalid inverse"),{x:u(t*r),y:u(e*r)}}toRawBytes(){const{x:t,y:e}=this.toAffine(),n=g(e);return n[31]|=1n&t?128:0,n}toHex(){return b(this.toRawBytes())}}y.BASE=new y(n,r,1n,u(n*r)),y.ZERO=new y(0n,1n,1n,0n);const{BASE:f,ZERO:p}=y,x=(t,e)=>t.toString(16).padStart(e,"0"),b=t=>Array.from(t).map((t=>x(t,2))).join(""),m=t=>{const e=t.length;(!i(t)||e%2)&&o("hex invalid 1");const n=c(e/2);for(let e=0;e<n.length;e++){const r=2*e,s=t.slice(r,r+2),i=Number.parseInt(s,16);(Number.isNaN(i)||i<0)&&o("hex invalid 2"),n[e]=i}return n},g=t=>m(x(t,64)).reverse(),w=t=>BigInt("0x"+b(c(a(t)).reverse())),B=(...t)=>{const e=c(t.reduce(((t,e)=>t+a(e).length),0));let n=0;return t.forEach((t=>{e.set(t,n),n+=t.length})),e},A=(e,n=t)=>{(0n===e||n<=0n)&&o("no inverse n="+e+" mod="+n);let r=u(e,n),s=n,i=0n,a=1n;for(;0n!==r;){const t=s%r,e=i-a*(s/r);s=r,r=t,i=a,a=e}return 1n===s?u(i,n):o("no inverse")},v=(e,n)=>{let r=e;for(;n-- >0n;)r*=r,r%=t;return r},R=19681161376707505956807079304988542015446066515923890162744021073123829784752n,E=(e,n)=>{const r=u(n*n*n),s=(e=>{const n=e*e%t*e%t,r=v(n,2n)*n%t,s=v(r,1n)*e%t,o=v(s,5n)*s%t,i=v(o,10n)*o%t,a=v(i,20n)*i%t,c=v(a,40n)*a%t,l=v(c,80n)*c%t,u=v(l,80n)*c%t,d=v(u,10n)*o%t;return{pow_p_5_8:v(d,2n)*e%t,b2:n}})(e*u(r*r*n)).pow_p_5_8;let o=u(e*r*s);const i=u(n*o*o),a=o,c=u(o*R),l=i===e,d=i===u(-e),h=i===u(-e*R);return l&&(o=a),(d||h)&&(o=c),1n===(1n&u(o))&&(o=u(-o)),{isValid:l||d,value:o}},S=t=>u(w(t),e);let z;const H=(...t)=>F.sha512Async(...t),P=(...t)=>"function"==typeof z?z(...t):o("etc.sha512Sync not set"),T=t=>{const e=t.slice(0,32);e[0]&=248,e[31]&=127,e[31]|=64;const n=t.slice(32,64),r=S(e),s=f.mul(r),o=s.toRawBytes();return{head:e,prefix:n,scalar:r,point:s,pointBytes:o}},_=t=>H(l(t,32)).then(T),I=t=>T(P(l(t,32))),N=t=>_(t).then((t=>t.pointBytes)),O=t=>I(t).pointBytes;function V(t,e){return t?H(e.hashable).then(e.finish):e.finish(P(e.hashable))}const j=(t,n,r)=>{const{pointBytes:s,scalar:o}=t,i=S(n),c=f.mul(i).toRawBytes();return{hashable:B(c,s,r),finish:t=>{const n=u(i+S(t)*o,e);return a(B(c,g(n)),64)}}},q=async(t,e)=>{const n=l(t),r=await _(e),s=await H(r.prefix,n);return V(!0,j(r,s,n))},C=(t,e)=>{const n=l(t),r=I(e),s=P(r.prefix,n);return V(!1,j(r,s,n))},K=(t,e,n)=>{e=l(e),t=l(t,64);const r=y.fromHex(n,!1),s=y.fromHex(t.slice(0,32),!1),o=w(t.slice(32,64)),i=f.mul(o,!1);return{hashable:B(s.toRawBytes(),r.toRawBytes(),e),finish:t=>{const e=S(t);return s.add(r.mul(e,!1)).add(i.negate()).clearCofactor().is0()}}},U=async(t,e,n)=>V(!0,K(t,e,n)),G=(t,e,n)=>V(!1,K(t,e,n)),Z=()=>"object"==typeof globalThis&&"crypto"in globalThis?globalThis.crypto:void 0,F={bytesToHex:b,hexToBytes:m,concatBytes:B,mod:u,invert:A,randomBytes:t=>{const e=Z();return e||o("crypto.getRandomValues must be defined"),e.getRandomValues(c(t))},sha512Async:async(...t)=>{const e=Z();e||o("crypto.subtle or etc.sha512Async must be defined");const n=B(...t);return c(await e.subtle.digest("SHA-512",n.buffer))},sha512Sync:void 0};Object.defineProperties(F,{sha512Sync:{configurable:!1,get:()=>z,set(t){z||(z=t)}}});const L={getExtendedPublicKeyAsync:_,getExtendedPublicKey:I,randomPrivateKey:()=>F.randomBytes(32),precompute:(t=8,e=f)=>(e.multiply(3n),e)},M=t=>{const e=h||(h=(()=>{const t=[];let e=f,n=e;for(let r=0;r<33;r++){n=e,t.push(n);for(let r=1;r<128;r++)n=n.add(e),t.push(n);e=n.double()}return t})()),n=(t,e)=>{let n=e.negate();return t?n:e};let r=p,s=f;const o=BigInt(255),i=BigInt(8);for(let a=0;a<33;a++){const c=128*a;let l=Number(t&o);t>>=i,l>128&&(l-=256,t+=1n);const u=c,d=c+Math.abs(l)-1,h=a%2!=0,y=l<0;0===l?s=s.add(n(h,e[u])):r=r.add(n(y,e[d]))}return{p:r,f:s}};export{s as CURVE,y as ExtendedPoint,F as etc,O as getPublicKey,N as getPublicKeyAsync,C as sign,q as signAsync,L as utils,G as verify,U as verifyAsync};export default null;
9//# sourceMappingURL=/sm/cd01a92102818c7c46dbe0b9ea67492dd1f0856266ce83a45f80684e6e019347.map
static/noble-sha512.js +8 -0
……@@ -0,0 +1,8 @@
1/**
2 * Bundled by jsDelivr using Rollup v2.79.2 and Terser v5.39.0.
3 * Original file: /npm/@noble/hashes@1.3.0/esm/sha512.js
4 *
5 * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
6 */
7import"./noble-crypto.js";function t(t){if(!Number.isSafeInteger(t)||t<0)throw new Error(`Wrong positive integer: ${t}`)}function e(t,...e){if(!(t instanceof Uint8Array))throw new TypeError("Expected Uint8Array");if(e.length>0&&!e.includes(t.length))throw new TypeError(`Expected Uint8Array of length ${e}, not of length=${t.length}`)}const s={number:t,bool:function(t){if("boolean"!=typeof t)throw new Error(`Expected boolean, not ${t}`)},bytes:e,hash:function(e){if("function"!=typeof e||"function"!=typeof e.create)throw new Error("Hash should be wrapped by utils.wrapConstructor");t(e.outputLen),t(e.blockLen)},exists:function(t,e=!0){if(t.destroyed)throw new Error("Hash instance has been destroyed");if(e&&t.finished)throw new Error("Hash#digest() has already been called")},output:function(t,s){e(t);const h=s.outputLen;if(t.length<h)throw new Error(`digestInto() expects output buffer of length at least ${h}`)}},h=t=>new DataView(t.buffer,t.byteOffset,t.byteLength);if(!(68===new Uint8Array(new Uint32Array([287454020]).buffer)[0]))throw new Error("Non little-endian hardware is not supported");function i(t){if("string"==typeof t&&(t=function(t){if("string"!=typeof t)throw new TypeError("utf8ToBytes expected string, got "+typeof t);return(new TextEncoder).encode(t)}(t)),!(t instanceof Uint8Array))throw new TypeError(`Expected input type is Uint8Array (got ${typeof t})`);return t}Array.from({length:256},((t,e)=>e.toString(16).padStart(2,"0")));class r{clone(){return this._cloneInto()}}function n(t){const e=e=>t().update(i(e)).digest(),s=t();return e.outputLen=s.outputLen,e.blockLen=s.blockLen,e.create=()=>t(),e}class o extends r{constructor(t,e,s,i){super(),this.blockLen=t,this.outputLen=e,this.padOffset=s,this.isLE=i,this.finished=!1,this.length=0,this.pos=0,this.destroyed=!1,this.buffer=new Uint8Array(t),this.view=h(this.buffer)}update(t){s.exists(this);const{view:e,buffer:r,blockLen:n}=this,o=(t=i(t)).length;for(let s=0;s<o;){const i=Math.min(n-this.pos,o-s);if(i!==n)r.set(t.subarray(s,s+i),this.pos),this.pos+=i,s+=i,this.pos===n&&(this.process(e,0),this.pos=0);else{const e=h(t);for(;n<=o-s;s+=n)this.process(e,s)}}return this.length+=t.length,this.roundClean(),this}digestInto(t){s.exists(this),s.output(t,this),this.finished=!0;const{buffer:e,view:i,blockLen:r,isLE:n}=this;let{pos:o}=this;e[o++]=128,this.buffer.subarray(o).fill(0),this.padOffset>r-o&&(this.process(i,0),o=0);for(let t=o;t<r;t++)e[t]=0;!function(t,e,s,h){if("function"==typeof t.setBigUint64)return t.setBigUint64(e,s,h);const i=BigInt(32),r=BigInt(4294967295),n=Number(s>>i&r),o=Number(s&r),c=h?4:0,a=h?0:4;t.setUint32(e+c,n,h),t.setUint32(e+a,o,h)}(i,r-8,BigInt(8*this.length),n),this.process(i,0);const c=h(t),a=this.outputLen;if(a%4)throw new Error("_sha2: outputLen should be aligned to 32bit");const f=a/4,d=this.get();if(f>d.length)throw new Error("_sha2: outputLen bigger than state");for(let t=0;t<f;t++)c.setUint32(4*t,d[t],n)}digest(){const{buffer:t,outputLen:e}=this;this.digestInto(t);const s=t.slice(0,e);return this.destroy(),s}_cloneInto(t){t||(t=new this.constructor),t.set(...this.get());const{blockLen:e,buffer:s,length:h,finished:i,destroyed:r,pos:n}=this;return t.length=h,t.pos=n,t.finished=i,t.destroyed=r,h%e&&t.buffer.set(s),t}}const c=BigInt(2**32-1),a=BigInt(32);function f(t,e=!1){return e?{h:Number(t&c),l:Number(t>>a&c)}:{h:0|Number(t>>a&c),l:0|Number(t&c)}}const d={fromBig:f,split:function(t,e=!1){let s=new Uint32Array(t.length),h=new Uint32Array(t.length);for(let i=0;i<t.length;i++){const{h:r,l:n}=f(t[i],e);[s[i],h[i]]=[r,n]}return[s,h]},toBig:(t,e)=>BigInt(t>>>0)<<a|BigInt(e>>>0),shrSH:(t,e,s)=>t>>>s,shrSL:(t,e,s)=>t<<32-s|e>>>s,rotrSH:(t,e,s)=>t>>>s|e<<32-s,rotrSL:(t,e,s)=>t<<32-s|e>>>s,rotrBH:(t,e,s)=>t<<64-s|e>>>s-32,rotrBL:(t,e,s)=>t>>>s-32|e<<64-s,rotr32H:(t,e)=>e,rotr32L:(t,e)=>t,rotlSH:(t,e,s)=>t<<s|e>>>32-s,rotlSL:(t,e,s)=>e<<s|t>>>32-s,rotlBH:(t,e,s)=>e<<s-32|t>>>64-s,rotlBL:(t,e,s)=>t<<s-32|e>>>64-s,add:function(t,e,s,h){const i=(e>>>0)+(h>>>0);return{h:t+s+(i/2**32|0)|0,l:0|i}},add3L:(t,e,s)=>(t>>>0)+(e>>>0)+(s>>>0),add3H:(t,e,s,h)=>e+s+h+(t/2**32|0)|0,add4L:(t,e,s,h)=>(t>>>0)+(e>>>0)+(s>>>0)+(h>>>0),add4H:(t,e,s,h,i)=>e+s+h+i+(t/2**32|0)|0,add5H:(t,e,s,h,i,r)=>e+s+h+i+r+(t/2**32|0)|0,add5L:(t,e,s,h,i)=>(t>>>0)+(e>>>0)+(s>>>0)+(h>>>0)+(i>>>0)},[l,b]=d.split(["0x428a2f98d728ae22","0x7137449123ef65cd","0xb5c0fbcfec4d3b2f","0xe9b5dba58189dbbc","0x3956c25bf348b538","0x59f111f1b605d019","0x923f82a4af194f9b","0xab1c5ed5da6d8118","0xd807aa98a3030242","0x12835b0145706fbe","0x243185be4ee4b28c","0x550c7dc3d5ffb4e2","0x72be5d74f27b896f","0x80deb1fe3b1696b1","0x9bdc06a725c71235","0xc19bf174cf692694","0xe49b69c19ef14ad2","0xefbe4786384f25e3","0x0fc19dc68b8cd5b5","0x240ca1cc77ac9c65","0x2de92c6f592b0275","0x4a7484aa6ea6e483","0x5cb0a9dcbd41fbd4","0x76f988da831153b5","0x983e5152ee66dfab","0xa831c66d2db43210","0xb00327c898fb213f","0xbf597fc7beef0ee4","0xc6e00bf33da88fc2","0xd5a79147930aa725","0x06ca6351e003826f","0x142929670a0e6e70","0x27b70a8546d22ffc","0x2e1b21385c26c926","0x4d2c6dfc5ac42aed","0x53380d139d95b3df","0x650a73548baf63de","0x766a0abb3c77b2a8","0x81c2c92e47edaee6","0x92722c851482353b","0xa2bfe8a14cf10364","0xa81a664bbc423001","0xc24b8b70d0f89791","0xc76c51a30654be30","0xd192e819d6ef5218","0xd69906245565a910","0xf40e35855771202a","0x106aa07032bbd1b8","0x19a4c116b8d2d0c8","0x1e376c085141ab53","0x2748774cdf8eeb99","0x34b0bcb5e19b48a8","0x391c0cb3c5c95a63","0x4ed8aa4ae3418acb","0x5b9cca4f7763e373","0x682e6ff3d6b2b8a3","0x748f82ee5defb2fc","0x78a5636f43172f60","0x84c87814a1f0ab72","0x8cc702081a6439ec","0x90befffa23631e28","0xa4506cebde82bde9","0xbef9a3f7b2c67915","0xc67178f2e372532b","0xca273eceea26619c","0xd186b8c721c0c207","0xeada7dd6cde0eb1e","0xf57d4f7fee6ed178","0x06f067aa72176fba","0x0a637dc5a2c898a6","0x113f9804bef90dae","0x1b710b35131c471b","0x28db77f523047d84","0x32caab7b40c72493","0x3c9ebe0a15c9bebc","0x431d67c49c100d4c","0x4cc5d4becb3e42b6","0x597f299cfc657e2a","0x5fcb6fab3ad6faec","0x6c44198c4a475817"].map((t=>BigInt(t)))),u=new Uint32Array(80),x=new Uint32Array(80);class p extends o{constructor(){super(128,64,16,!1),this.Ah=1779033703,this.Al=-205731576,this.Bh=-1150833019,this.Bl=-2067093701,this.Ch=1013904242,this.Cl=-23791573,this.Dh=-1521486534,this.Dl=1595750129,this.Eh=1359893119,this.El=-1377402159,this.Fh=-1694144372,this.Fl=725511199,this.Gh=528734635,this.Gl=-79577749,this.Hh=1541459225,this.Hl=327033209}get(){const{Ah:t,Al:e,Bh:s,Bl:h,Ch:i,Cl:r,Dh:n,Dl:o,Eh:c,El:a,Fh:f,Fl:d,Gh:l,Gl:b,Hh:u,Hl:x}=this;return[t,e,s,h,i,r,n,o,c,a,f,d,l,b,u,x]}set(t,e,s,h,i,r,n,o,c,a,f,d,l,b,u,x){this.Ah=0|t,this.Al=0|e,this.Bh=0|s,this.Bl=0|h,this.Ch=0|i,this.Cl=0|r,this.Dh=0|n,this.Dl=0|o,this.Eh=0|c,this.El=0|a,this.Fh=0|f,this.Fl=0|d,this.Gh=0|l,this.Gl=0|b,this.Hh=0|u,this.Hl=0|x}process(t,e){for(let s=0;s<16;s++,e+=4)u[s]=t.getUint32(e),x[s]=t.getUint32(e+=4);for(let t=16;t<80;t++){const e=0|u[t-15],s=0|x[t-15],h=d.rotrSH(e,s,1)^d.rotrSH(e,s,8)^d.shrSH(e,s,7),i=d.rotrSL(e,s,1)^d.rotrSL(e,s,8)^d.shrSL(e,s,7),r=0|u[t-2],n=0|x[t-2],o=d.rotrSH(r,n,19)^d.rotrBH(r,n,61)^d.shrSH(r,n,6),c=d.rotrSL(r,n,19)^d.rotrBL(r,n,61)^d.shrSL(r,n,6),a=d.add4L(i,c,x[t-7],x[t-16]),f=d.add4H(a,h,o,u[t-7],u[t-16]);u[t]=0|f,x[t]=0|a}let{Ah:s,Al:h,Bh:i,Bl:r,Ch:n,Cl:o,Dh:c,Dl:a,Eh:f,El:p,Fh:g,Fl:w,Gh:L,Gl:H,Hh:y,Hl:B}=this;for(let t=0;t<80;t++){const e=d.rotrSH(f,p,14)^d.rotrSH(f,p,18)^d.rotrBH(f,p,41),E=d.rotrSL(f,p,14)^d.rotrSL(f,p,18)^d.rotrBL(f,p,41),A=f&g^~f&L,S=p&w^~p&H,C=d.add5L(B,E,S,b[t],x[t]),U=d.add5H(C,y,e,A,l[t],u[t]),D=0|C,F=d.rotrSH(s,h,28)^d.rotrBH(s,h,34)^d.rotrBH(s,h,39),G=d.rotrSL(s,h,28)^d.rotrBL(s,h,34)^d.rotrBL(s,h,39),m=s&i^s&n^i&n,I=h&r^h&o^r&o;y=0|L,B=0|H,L=0|g,H=0|w,g=0|f,w=0|p,({h:f,l:p}=d.add(0|c,0|a,0|U,0|D)),c=0|n,a=0|o,n=0|i,o=0|r,i=0|s,r=0|h;const N=d.add3L(D,G,I);s=d.add3H(N,U,F,m),h=0|N}({h:s,l:h}=d.add(0|this.Ah,0|this.Al,0|s,0|h)),({h:i,l:r}=d.add(0|this.Bh,0|this.Bl,0|i,0|r)),({h:n,l:o}=d.add(0|this.Ch,0|this.Cl,0|n,0|o)),({h:c,l:a}=d.add(0|this.Dh,0|this.Dl,0|c,0|a)),({h:f,l:p}=d.add(0|this.Eh,0|this.El,0|f,0|p)),({h:g,l:w}=d.add(0|this.Fh,0|this.Fl,0|g,0|w)),({h:L,l:H}=d.add(0|this.Gh,0|this.Gl,0|L,0|H)),({h:y,l:B}=d.add(0|this.Hh,0|this.Hl,0|y,0|B)),this.set(s,h,i,r,n,o,c,a,f,p,g,w,L,H,y,B)}roundClean(){u.fill(0),x.fill(0)}destroy(){this.buffer.fill(0),this.set(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0)}}class g extends p{constructor(){super(),this.Ah=-1942145080,this.Al=424955298,this.Bh=1944164710,this.Bl=-1982016298,this.Ch=502970286,this.Cl=855612546,this.Dh=1738396948,this.Dl=1479516111,this.Eh=258812777,this.El=2077511080,this.Fh=2011393907,this.Fl=79989058,this.Gh=1067287976,this.Gl=1780299464,this.Hh=286451373,this.Hl=-1848208735,this.outputLen=28}}class w extends p{constructor(){super(),this.Ah=573645204,this.Al=-64227540,this.Bh=-1621794909,this.Bl=-934517566,this.Ch=596883563,this.Cl=1867755857,this.Dh=-1774684391,this.Dl=1497426621,this.Eh=-1775747358,this.El=-1467023389,this.Fh=-1101128155,this.Fl=1401305490,this.Gh=721525244,this.Gl=746961066,this.Hh=246885852,this.Hl=-2117784414,this.outputLen=32}}class L extends p{constructor(){super(),this.Ah=-876896931,this.Al=-1056596264,this.Bh=1654270250,this.Bl=914150663,this.Ch=-1856437926,this.Cl=812702999,this.Dh=355462360,this.Dl=-150054599,this.Eh=1731405415,this.El=-4191439,this.Fh=-1900787065,this.Fl=1750603025,this.Gh=-619958771,this.Gl=1694076839,this.Hh=1203062813,this.Hl=-1090891868,this.outputLen=48}}const H=n((()=>new p)),y=n((()=>new g)),B=n((()=>new w)),E=n((()=>new L));export{p as SHA512,E as sha384,H as sha512,y as sha512_224,B as sha512_256};export default null;
8//# sourceMappingURL=/sm/25ef7ec32d52ab376d05edb8d663f6f9885235f7c4d8ebc8cb0aab306fd143bb.map
test/admin-auth.test.js +123 -0
……@@ -0,0 +1,123 @@
1// The gate on the operator surface.
2//
3// ⛔ WHY THIS EXISTS AT ALL. The surface has to be reachable from the network, and browsers.js is
4// explicit that plain first-claim TOFU is safe only while the surface binds loopback — "exposing it
5// before claiming it hands it to whoever is on the network." So the first claim is bound to a bootstrap
6// token printed in the service log, and everything else is bound to a live session.
7
8import test from 'brittle'
9import Corestore from 'corestore'
10import fs from 'bare-fs'
11import os from 'bare-os'
12import { join } from 'bare-path'
13import { an } from 'anproto'
14import { Browsers } from 'favela-peer/lib/browsers.js'
15import { MemoryRegistrations } from '../lib/registrations.js'
16import { serveAdmin } from '../lib/admin-surface.js'
17import { Sessions, COOKIE } from '../lib/admin-auth.js'
18
19const ROOT = new URL('..', import.meta.url).pathname
20const TOKEN = 'b7f4c1a9e2d05638b7f4c1a9e2d05638'
21const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'ap-admin-'))
22
23async function ctx ({ bootstrap = TOKEN } = {}) {
24 const browsers = new Browsers(new Corestore(tmp()), { bootstrap })
25 await browsers.ready()
26 const registrations = new MemoryRegistrations({ policy: 'open' })
27 await registrations.claim({ username: 'fleeky', root: 'a'.repeat(64) })
28 return {
29 browsers,
30 registrations,
31 sessions: new Sessions(),
32 inbound: null,
33 root: ROOT,
34 config: { host: 'ap.7rnx.net', registration: 'open', inboundCap: 500 }
35 }
36}
37
38async function browser () {
39 const keypair = await an.gen()
40 return {
41 publicKey: an.publicKey(keypair),
42 async proof (c) {
43 const { challenge } = c.browsers.challenge()
44 return { publicKey: an.publicKey(keypair), challenge, signature: await an.sign(await an.hash(challenge), keypair) }
45 }
46 }
47}
48
49const req = (method, path, extra = {}) => ({ method, path, headers: {}, ...extra })
50
51test('★★★ every page redirects to sign-in without a session', async (t) => {
52 const c = await ctx()
53 for (const path of ['/admin', '/admin/', '/']) {
54 const res = await serveAdmin(req('GET', path), c)
55 t.is(res.status, 303, path + ' is gated')
56 t.is(res.headers.location, '/admin/login')
57 }
58})
59
60test('★★★ a proved key alone cannot claim the bridge — the token is required', async (t) => {
61 const c = await ctx()
62 const who = await browser()
63 const res = await serveAdmin(req('POST', '/admin/session', { form: await who.proof(c) }), c)
64 t.is(res.status, 403)
65 t.ok(/bootstrap token/.test(res.body), res.body)
66 t.absent(res.headers['set-cookie'], 'and no session was handed out')
67})
68
69test('★★★ the token claims it, and the cookie is HttpOnly + SameSite=Strict', async (t) => {
70 const c = await ctx()
71 const who = await browser()
72 const res = await serveAdmin(req('POST', '/admin/session', { form: { ...(await who.proof(c)), bootstrap: TOKEN } }), c)
73 t.is(res.status, 200)
74 const cookie = res.headers['set-cookie']
75 t.ok(cookie && cookie.startsWith(COOKIE + '='))
76 t.ok(/HttpOnly/.test(cookie), 'a page cannot read it')
77 t.ok(/SameSite=Strict/.test(cookie), 'and it does not ride cross-site requests')
78})
79
80test('★★★ with the session the surface opens, and shows the operator their users', async (t) => {
81 const c = await ctx()
82 const who = await browser()
83 const login = await serveAdmin(req('POST', '/admin/session', { form: { ...(await who.proof(c)), bootstrap: TOKEN } }), c)
84 const id = login.headers['set-cookie'].split(';')[0].split('=')[1]
85
86 const res = await serveAdmin(req('GET', '/admin', { headers: { cookie: COOKIE + '=' + id } }), c)
87 t.is(res.status, 200)
88 t.ok(/fleeky@ap\.7rnx\.net/.test(res.body), 'the sanity check the surface exists for')
89})
90
91test('★★★ a revoked browser loses the surface on its NEXT request', async (t) => {
92 // ⛔ The cookie is not the authority. Holding a session must never outlive the grant behind it, so
93 // the registry is re-asked per request rather than trusted at login.
94 const c = await ctx()
95 const who = await browser()
96 const login = await serveAdmin(req('POST', '/admin/session', { form: { ...(await who.proof(c)), bootstrap: TOKEN } }), c)
97 const id = login.headers['set-cookie'].split(';')[0].split('=')[1]
98 const cookie = { cookie: COOKIE + '=' + id }
99
100 t.is((await serveAdmin(req('GET', '/admin', { headers: cookie }), c)).status, 200, 'in')
101 await c.browsers.revoke(who.publicKey)
102 t.is((await serveAdmin(req('GET', '/admin', { headers: cookie }), c)).status, 303, 'and out again immediately')
103})
104
105test('★★ the browser identity client is served, and nothing else is', async (t) => {
106 const c = await ctx()
107 const ok = await serveAdmin(req('GET', '/static/anproto-client.js'), c)
108 t.is(ok.status, 200)
109 t.ok(/ANProtoClient/.test(String(ok.body)))
110
111 // ⛔ A fixed list, not a directory join — a path off the wire joined to a root is a traversal bug.
112 for (const evil of ['/static/../lib/admin.js', '/static/../../../etc/passwd', '/static/index.js']) {
113 t.is((await serveAdmin(req('GET', evil), c)).status, 404, evil + ' is refused')
114 }
115})
116
117test('★★ sign-in is reachable without a session, and says what it needs', async (t) => {
118 const c = await ctx()
119 const res = await serveAdmin(req('GET', '/admin/login'), c)
120 t.is(res.status, 200)
121 t.ok(/bootstrap token/i.test(res.body), 'an unclaimed bridge asks for the token')
122 t.ok(/anproto-client\.js/.test(res.body), 'and loads the same identity client the other apps use')
123})