fedipod 0.8.0
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/LICENSE +21 -0
- package/README.md +123 -0
- package/architecture.svg +89 -0
- package/bin/fedipod.mjs +2303 -0
- package/cli.md +243 -0
- package/groups.md +85 -0
- package/gui.md +99 -0
- package/lib/account.mjs +87 -0
- package/lib/admin.mjs +1606 -0
- package/lib/atproto.mjs +310 -0
- package/lib/bskyfeed.mjs +166 -0
- package/lib/bskygroup.mjs +162 -0
- package/lib/c2s.mjs +305 -0
- package/lib/certs.mjs +265 -0
- package/lib/deliver.mjs +249 -0
- package/lib/directory.mjs +90 -0
- package/lib/embed.mjs +214 -0
- package/lib/export-collections.mjs +161 -0
- package/lib/front-core.mjs +322 -0
- package/lib/gateway-core.mjs +115 -0
- package/lib/guard.mjs +213 -0
- package/lib/home.mjs +161 -0
- package/lib/httpsig.mjs +109 -0
- package/lib/import.mjs +357 -0
- package/lib/intake.mjs +1710 -0
- package/lib/keys.mjs +139 -0
- package/lib/lease.mjs +190 -0
- package/lib/localapi.mjs +87 -0
- package/lib/mastoapi.mjs +1588 -0
- package/lib/migrate.mjs +127 -0
- package/lib/oidc-auth.mjs +53 -0
- package/lib/podrdf.mjs +132 -0
- package/lib/ports.mjs +27 -0
- package/lib/publisher.mjs +1003 -0
- package/lib/remote.mjs +303 -0
- package/lib/safefetch.mjs +235 -0
- package/lib/setup.mjs +262 -0
- package/lib/social.mjs +505 -0
- package/lib/storage.mjs +170 -0
- package/lib/store.mjs +470 -0
- package/lib/streaming.mjs +154 -0
- package/lib/tagfeed.mjs +158 -0
- package/lib/ua.mjs +29 -0
- package/lib/update.mjs +91 -0
- package/lib/webpush.mjs +98 -0
- package/lib/wire.mjs +762 -0
- package/package.json +60 -0
- package/phanpy/dist/404.html +40 -0
- package/phanpy/dist/_headers +2 -0
- package/phanpy/dist/apple-touch-icon.png +0 -0
- package/phanpy/dist/assets/ICONS-DIPJeU0C.js +2 -0
- package/phanpy/dist/assets/ICONS-DIPJeU0C.js.map +1 -0
- package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js +183 -0
- package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
- package/phanpy/dist/assets/Temml-CCjoacWt.woff2 +0 -0
- package/phanpy/dist/assets/__vite-browser-external-C7iut881.js +2 -0
- package/phanpy/dist/assets/__vite-browser-external-C7iut881.js.map +1 -0
- package/phanpy/dist/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
- package/phanpy/dist/assets/catch-up-O5q5C75Z.png +0 -0
- package/phanpy/dist/assets/chunk-aKtaBQYM.js +1 -0
- package/phanpy/dist/assets/compose-CFEx7wb4.js +2 -0
- package/phanpy/dist/assets/compose-CFEx7wb4.js.map +1 -0
- package/phanpy/dist/assets/compose-ZJN5xSb4.js +35 -0
- package/phanpy/dist/assets/compose-ZJN5xSb4.js.map +1 -0
- package/phanpy/dist/assets/debug.module-YFQgwnIr.js +52 -0
- package/phanpy/dist/assets/debug.module-YFQgwnIr.js.map +1 -0
- package/phanpy/dist/assets/dom-p1YcJ5Rw.js +2 -0
- package/phanpy/dist/assets/dom-p1YcJ5Rw.js.map +1 -0
- package/phanpy/dist/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
- package/phanpy/dist/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
- package/phanpy/dist/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
- package/phanpy/dist/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
- package/phanpy/dist/assets/icons/add-circle-line-WD0mwORD.js +1 -0
- package/phanpy/dist/assets/icons/alert-line-CpFUMf9s.js +1 -0
- package/phanpy/dist/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
- package/phanpy/dist/assets/icons/announcement-line-5nwaJFJm.js +1 -0
- package/phanpy/dist/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
- package/phanpy/dist/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
- package/phanpy/dist/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
- package/phanpy/dist/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
- package/phanpy/dist/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
- package/phanpy/dist/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
- package/phanpy/dist/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
- package/phanpy/dist/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
- package/phanpy/dist/assets/icons/at-line-CF66Bznk.js +1 -0
- package/phanpy/dist/assets/icons/attachment-line-BY8It-l9.js +1 -0
- package/phanpy/dist/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
- package/phanpy/dist/assets/icons/board-line-C0UWXyA6.js +1 -0
- package/phanpy/dist/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
- package/phanpy/dist/assets/icons/building-5-line-Dd59MzbO.js +1 -0
- package/phanpy/dist/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
- package/phanpy/dist/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
- package/phanpy/dist/assets/icons/calendar-month-line-vommts2C.js +1 -0
- package/phanpy/dist/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
- package/phanpy/dist/assets/icons/camera-line-Cv69MEei.js +1 -0
- package/phanpy/dist/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
- package/phanpy/dist/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
- package/phanpy/dist/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
- package/phanpy/dist/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
- package/phanpy/dist/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
- package/phanpy/dist/assets/icons/clipboard-line-BBp364gJ.js +1 -0
- package/phanpy/dist/assets/icons/close-circle-line-CICdiuGz.js +1 -0
- package/phanpy/dist/assets/icons/close-line-DYrFWcjv.js +1 -0
- package/phanpy/dist/assets/icons/cloud-line-tiQAbah8.js +1 -0
- package/phanpy/dist/assets/icons/code-line-fAl8iiV3.js +1 -0
- package/phanpy/dist/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
- package/phanpy/dist/assets/icons/copy-2-line-CLW69yF5.js +1 -0
- package/phanpy/dist/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
- package/phanpy/dist/assets/icons/document-3-line-DBGNhAg2.js +1 -0
- package/phanpy/dist/assets/icons/document-line-C7mb9sb7.js +1 -0
- package/phanpy/dist/assets/icons/down-line-BGqSSThd.js +1 -0
- package/phanpy/dist/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
- package/phanpy/dist/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
- package/phanpy/dist/assets/icons/exit-line-Dc07BS-x.js +1 -0
- package/phanpy/dist/assets/icons/external-link-line-BvpSkmjO.js +1 -0
- package/phanpy/dist/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
- package/phanpy/dist/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
- package/phanpy/dist/assets/icons/filter-2-line-B_mR2usy.js +1 -0
- package/phanpy/dist/assets/icons/filter-line-C_nm-3cU.js +1 -0
- package/phanpy/dist/assets/icons/flag-1-line-C2HB05I0.js +1 -0
- package/phanpy/dist/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
- package/phanpy/dist/assets/icons/formula-line-DLrt2d6j.js +1 -0
- package/phanpy/dist/assets/icons/grid-line-CgUmEikf.js +1 -0
- package/phanpy/dist/assets/icons/group-line-C5fnX0wU.js +1 -0
- package/phanpy/dist/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
- package/phanpy/dist/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
- package/phanpy/dist/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
- package/phanpy/dist/assets/icons/heart-line-CJ7K47J4.js +1 -0
- package/phanpy/dist/assets/icons/history-2-line-kWWvFzqv.js +1 -0
- package/phanpy/dist/assets/icons/history-line-bGQiwbTH.js +1 -0
- package/phanpy/dist/assets/icons/home-3-line-fRU1zeLG.js +1 -0
- package/phanpy/dist/assets/icons/information-line-iNk6-sOY.js +1 -0
- package/phanpy/dist/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
- package/phanpy/dist/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
- package/phanpy/dist/assets/icons/layout-5-line-B_znt5wO.js +1 -0
- package/phanpy/dist/assets/icons/left-line-eUw0tya4.js +1 -0
- package/phanpy/dist/assets/icons/lightning-line-DaKoDdix.js +1 -0
- package/phanpy/dist/assets/icons/link-2-line-DCqLmMA7.js +1 -0
- package/phanpy/dist/assets/icons/list-check-line-BAxosBh9.js +1 -0
- package/phanpy/dist/assets/icons/lock-line-CPoaXeUW.js +1 -0
- package/phanpy/dist/assets/icons/mail-line-B0P8aa1L.js +1 -0
- package/phanpy/dist/assets/icons/moon-line-CEqNAToP.js +1 -0
- package/phanpy/dist/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
- package/phanpy/dist/assets/icons/more-3-line-BIiVjYTz.js +1 -0
- package/phanpy/dist/assets/icons/notification-line-BZZq2Gtu.js +1 -0
- package/phanpy/dist/assets/icons/pencil-line-CKh8-A1v.js +1 -0
- package/phanpy/dist/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
- package/phanpy/dist/assets/icons/pin-line-DpTolyhs.js +1 -0
- package/phanpy/dist/assets/icons/play-fill-B0-G_8lX.js +1 -0
- package/phanpy/dist/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
- package/phanpy/dist/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
- package/phanpy/dist/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
- package/phanpy/dist/assets/icons/radar-line-CKyroTth.js +1 -0
- package/phanpy/dist/assets/icons/react-line-CoIKhhtK.js +1 -0
- package/phanpy/dist/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
- package/phanpy/dist/assets/icons/right-line-B3KWJaqc.js +1 -0
- package/phanpy/dist/assets/icons/rocket-line-4KZl537D.js +1 -0
- package/phanpy/dist/assets/icons/round-fill-C69NTrZ0.js +1 -0
- package/phanpy/dist/assets/icons/round-line-B9k_1uo-.js +1 -0
- package/phanpy/dist/assets/icons/route-line-B28ReHcE.js +1 -0
- package/phanpy/dist/assets/icons/rows-4-line-CrBniagM.js +1 -0
- package/phanpy/dist/assets/icons/scan-line-DSf74YdP.js +1 -0
- package/phanpy/dist/assets/icons/search-2-line-B6geIU1z.js +1 -0
- package/phanpy/dist/assets/icons/settings-3-line-azJwK47w.js +1 -0
- package/phanpy/dist/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
- package/phanpy/dist/assets/icons/share-2-line-D5MxWjrm.js +1 -0
- package/phanpy/dist/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
- package/phanpy/dist/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
- package/phanpy/dist/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
- package/phanpy/dist/assets/icons/time-line-BF3eet1v.js +1 -0
- package/phanpy/dist/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
- package/phanpy/dist/assets/icons/translate-line-BFWol2Ce.js +1 -0
- package/phanpy/dist/assets/icons/unlock-line-X_x5vwv3.js +1 -0
- package/phanpy/dist/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
- package/phanpy/dist/assets/icons/user-4-line-BVSynZCp.js +1 -0
- package/phanpy/dist/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
- package/phanpy/dist/assets/icons/user-add-line-BbXdHG4z.js +1 -0
- package/phanpy/dist/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
- package/phanpy/dist/assets/icons/user-follow-line-BXux0wSd.js +1 -0
- package/phanpy/dist/assets/icons/user-star-line-DdYlpHLU.js +1 -0
- package/phanpy/dist/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
- package/phanpy/dist/assets/icons/user-x-line-DIDgNlPH.js +1 -0
- package/phanpy/dist/assets/icons/volume-line-Bs390KQt.js +1 -0
- package/phanpy/dist/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
- package/phanpy/dist/assets/icons/walk-line-yNXfB-md.js +1 -0
- package/phanpy/dist/assets/icons/world-2-line-CenkGIwj.js +1 -0
- package/phanpy/dist/assets/icons/zoom-in-line-CaENUphf.js +1 -0
- package/phanpy/dist/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
- package/phanpy/dist/assets/instances-BNPptnn-.json +333 -0
- package/phanpy/dist/assets/locales/ar-SA-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/ca-ES-DHRW86Tz.js +1 -0
- package/phanpy/dist/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
- package/phanpy/dist/assets/locales/de-DE-ByRPMRFy.js +1 -0
- package/phanpy/dist/assets/locales/eo-UY-CPC7wLCS.js +1 -0
- package/phanpy/dist/assets/locales/es-ES-dJa0Fd9t.js +1 -0
- package/phanpy/dist/assets/locales/eu-ES-C3djlPR2.js +1 -0
- package/phanpy/dist/assets/locales/fa-IR-DTT490Ck.js +1 -0
- package/phanpy/dist/assets/locales/fi-FI-B23MCPuz.js +1 -0
- package/phanpy/dist/assets/locales/fr-FR-BcaQsO5C.js +1 -0
- package/phanpy/dist/assets/locales/gl-ES-BR-CVKBN.js +1 -0
- package/phanpy/dist/assets/locales/he-IL-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/hu-HU-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/it-IT-C1136UFV.js +1 -0
- package/phanpy/dist/assets/locales/ja-JP-CRZmRAhD.js +1 -0
- package/phanpy/dist/assets/locales/kab-Cz8xH_TT.js +1 -0
- package/phanpy/dist/assets/locales/ko-KR-CG9UiY4o.js +1 -0
- package/phanpy/dist/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
- package/phanpy/dist/assets/locales/nb-NO-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
- package/phanpy/dist/assets/locales/oc-FR-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/pl-PL-DkwGXf6P.js +1 -0
- package/phanpy/dist/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
- package/phanpy/dist/assets/locales/pt-BR-nGL0HdFS.js +1 -0
- package/phanpy/dist/assets/locales/pt-PT-Cp17aj2z.js +1 -0
- package/phanpy/dist/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
- package/phanpy/dist/assets/locales/th-TH-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/tok-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/tr-TR-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/uk-UA-C5E3dFBf.js +1 -0
- package/phanpy/dist/assets/locales/zh-CN-BHJi7_et.js +1 -0
- package/phanpy/dist/assets/locales/zh-TW-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/main-BdqNbG-a.js +26 -0
- package/phanpy/dist/assets/main-BdqNbG-a.js.map +1 -0
- package/phanpy/dist/assets/mock-home-DGhR__He.js +2 -0
- package/phanpy/dist/assets/mock-home-DGhR__He.js.map +1 -0
- package/phanpy/dist/assets/multi-column-ROck0NVt.jpg +0 -0
- package/phanpy/dist/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
- package/phanpy/dist/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
- package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js +2 -0
- package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js.map +1 -0
- package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js +20 -0
- package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js.map +1 -0
- package/phanpy/dist/assets/style-B1iLPTPR.css +2 -0
- package/phanpy/dist/assets/temml-DuyIQMei.js +97 -0
- package/phanpy/dist/assets/temml-DuyIQMei.js.map +1 -0
- package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js +2 -0
- package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
- package/phanpy/dist/assets/year-in-posts-B2lywqdx.png +0 -0
- package/phanpy/dist/assets/year-in-posts-DJv_star.js +3 -0
- package/phanpy/dist/assets/year-in-posts-DJv_star.js.map +1 -0
- package/phanpy/dist/compose/index.html +20 -0
- package/phanpy/dist/favicon.ico +0 -0
- package/phanpy/dist/index.html +73 -0
- package/phanpy/dist/logo-192.png +0 -0
- package/phanpy/dist/logo-512.png +0 -0
- package/phanpy/dist/logo-badge-72.png +0 -0
- package/phanpy/dist/logo-maskable-512.png +0 -0
- package/phanpy/dist/logo-monochrome-512.png +0 -0
- package/phanpy/dist/logo-monochrome-maskable-512.png +0 -0
- package/phanpy/dist/manifest.webmanifest +1 -0
- package/phanpy/dist/og-image-2.jpg +0 -0
- package/phanpy/dist/og-image.png +0 -0
- package/phanpy/dist/robots.txt +2 -0
- package/phanpy/dist/sw.js +25 -0
- package/phanpy/dist/version.json +1 -0
- package/run-agent.mjs +805 -0
- package/scripts/build-dist.mjs +37 -0
- package/scripts/verify-ui.mjs +60 -0
- package/ui/README.md +23 -0
- package/vendor/gate.cjs +132 -0
- package/vendor/idp-grant.cjs +394 -0
- package/vendor/jfetch.cjs +16 -0
- package/web/admin/admin.js +958 -0
- package/web/admin/bar.css +64 -0
- package/web/admin/bar.js +61 -0
- package/web/admin/client/client.js +66 -0
- package/web/admin/client/index.html +42 -0
- package/web/admin/index.html +493 -0
- package/web/admin/setup/index.html +172 -0
- package/web/admin/setup/setup.js +268 -0
- package/web/admin/window.css +55 -0
- package/web/admin/window.js +136 -0
- package/web/front/install.sh +69 -0
- package/web/front/new-account.html +424 -0
- package/web/front/run.html +138 -0
- package/web/front/solid-client-authn.bundle.js +2 -0
package/lib/guard.mjs
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// guard.mjs — the browser-facing firewall. A loopback HTTP server is
|
|
2
|
+
// reachable by any web page the user visits, and DNS rebinding lets such a
|
|
3
|
+
// page keep its own origin while its requests land here — so binding to
|
|
4
|
+
// loopback is NOT an access control. Two header checks close that door:
|
|
5
|
+
//
|
|
6
|
+
// Host must name a loopback authority we expect (or an operator-declared
|
|
7
|
+
// host) — a rebound `evil.example` fails here.
|
|
8
|
+
// Origin when present, must be one of those same authorities — a
|
|
9
|
+
// cross-origin fetch/WebSocket fails here even without rebinding.
|
|
10
|
+
//
|
|
11
|
+
// AP_ALLOWED_HOSTS (comma-separated host[:port]) extends the set for
|
|
12
|
+
// deliberate exposure (tailnet name, reverse-proxy domain).
|
|
13
|
+
//
|
|
14
|
+
// An agent also answers at <handle>.localhost, so each identity on a machine
|
|
15
|
+
// gets an origin of its own — one browser storage jar per actor, instead of
|
|
16
|
+
// every agent sharing localhost:<port> and asking which instance you meant.
|
|
17
|
+
// `.localhost` is reserved by RFC 6761 and cannot be delegated in public DNS,
|
|
18
|
+
// so it is not the rebinding hole this file exists to close; the label comes
|
|
19
|
+
// from config, never from a request header, and matching stays exact-string.
|
|
20
|
+
|
|
21
|
+
const LOOPBACK_NAMES = ['localhost', '127.0.0.1', '[::1]', '::1'];
|
|
22
|
+
|
|
23
|
+
// A handle earns a named origin only when it is already a clean DNS label.
|
|
24
|
+
// Nothing is rewritten: a mangled name would be a different origin from the
|
|
25
|
+
// one we printed, and the user would land on a 403 with no way to see why.
|
|
26
|
+
export function hostLabel(handle) {
|
|
27
|
+
const s = String(handle || '').toLowerCase();
|
|
28
|
+
return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(s) ? s : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The authorities that are this machine, and only this machine.
|
|
32
|
+
//
|
|
33
|
+
// The port-less form is a DIFFERENT origin: `http://localhost` means port 80
|
|
34
|
+
// and `https://localhost` means 443. Emitting it whatever port we are on put
|
|
35
|
+
// every other local server's pages inside our own origin set — a page served
|
|
36
|
+
// from 127.0.0.1:80 sends `Origin: http://localhost`, which passed the Origin
|
|
37
|
+
// check, and a form POST needs no preflight to reach a write route. So the
|
|
38
|
+
// bare form is emitted only when it really is this server's own authority.
|
|
39
|
+
export function loopbackAuthorities(port, labels = []) {
|
|
40
|
+
const out = [];
|
|
41
|
+
const bare = Number(port) === 80 || Number(port) === 443;
|
|
42
|
+
for (const name of LOOPBACK_NAMES) {
|
|
43
|
+
out.push(`${name}:${port}`);
|
|
44
|
+
if (bare) out.push(name);
|
|
45
|
+
}
|
|
46
|
+
for (const l of labels) {
|
|
47
|
+
const label = hostLabel(l);
|
|
48
|
+
if (!label) continue;
|
|
49
|
+
out.push(`${label}.localhost:${port}`);
|
|
50
|
+
if (bare) out.push(`${label}.localhost`);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
56
|
+
|
|
57
|
+
// Where the connection came FROM, as opposed to which authority it asked for.
|
|
58
|
+
// Both listeners bind loopback, so today this is true of everything that
|
|
59
|
+
// arrives — including a request relayed by a same-host reverse proxy, which is
|
|
60
|
+
// exactly why it is not what keeps an exposed agent safe (see exposureProblem).
|
|
61
|
+
// It is here so that binding to another address later cannot silently turn the
|
|
62
|
+
// local-only routes into public ones.
|
|
63
|
+
export function isLoopbackSocket(req) {
|
|
64
|
+
const a = req?.socket?.remoteAddress;
|
|
65
|
+
if (!a) return false;
|
|
66
|
+
return LOOPBACK_ADDRESSES.has(a) || a.startsWith('127.');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// AP_ALLOWED_HOSTS is the documented way to put the agent on a tailnet name or
|
|
70
|
+
// behind a reverse proxy. Doing so removes every transport signal that told the
|
|
71
|
+
// operator apart from everyone else: the listeners bind loopback, so exposure
|
|
72
|
+
// means something on this machine is relaying, and a stock
|
|
73
|
+
// `proxy_pass http://127.0.0.1:<port>;` rewrites Host to a loopback authority
|
|
74
|
+
// and connects from loopback. Every caller then looks local, which is what the
|
|
75
|
+
// local-only routes and the password-less authorize path were trusting.
|
|
76
|
+
//
|
|
77
|
+
// A secret is all that is left, and AP_GATE_TOKEN is the one every route
|
|
78
|
+
// checks — a uiPassword guards the OAuth login and nothing else. Returns a
|
|
79
|
+
// reason string when the agent must not start, or null.
|
|
80
|
+
export function exposureProblem({ allowedHosts, gateToken } = {}) {
|
|
81
|
+
const exposed = String(allowedHosts || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
82
|
+
if (!exposed.length || gateToken) return null;
|
|
83
|
+
return `AP_ALLOWED_HOSTS names ${exposed.join(', ')}, but AP_GATE_TOKEN is not set.\n`
|
|
84
|
+
+ 'An exposed agent cannot tell your requests from anyone else\'s — a reverse proxy\n'
|
|
85
|
+
+ 'rewrites Host to a loopback authority and connects from loopback, so every caller\n'
|
|
86
|
+
+ 'looks like this machine. Without a shared secret that means the admin API, the\n'
|
|
87
|
+
+ 'setup routes and the OAuth mint answer whoever reaches the address.\n\n'
|
|
88
|
+
+ 'Either set AP_GATE_TOKEN to a secret (send it as the x-dk-token header, or visit\n'
|
|
89
|
+
+ '?dk-token=<secret> once to be given the cookie), or unset AP_ALLOWED_HOSTS.';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function allowedAuthorities(port, labels = []) {
|
|
93
|
+
const extra = String(process.env.AP_ALLOWED_HOSTS || '')
|
|
94
|
+
.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
95
|
+
return new Set([...loopbackAuthorities(port, labels), ...extra]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The set has to stay live. startAdmin runs before connect, so an unconfigured
|
|
99
|
+
// agent has no handle yet — and MastoApi consults this same object to decide
|
|
100
|
+
// whether an OAuth redirect_uri is one of ours, so a snapshot taken at listen
|
|
101
|
+
// time would reject the named origin for the life of the process.
|
|
102
|
+
export class Authorities {
|
|
103
|
+
constructor(port, handle = null, extraPorts = []) {
|
|
104
|
+
this.port = port;
|
|
105
|
+
// The https listener's port: same machine, same names, second authority.
|
|
106
|
+
this.extraPorts = extraPorts.filter(Boolean);
|
|
107
|
+
this.label = null;
|
|
108
|
+
this.set = null;
|
|
109
|
+
this.local = null;
|
|
110
|
+
this.rebuild();
|
|
111
|
+
this.setHandle(handle);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
rebuild() {
|
|
115
|
+
const labels = this.labels();
|
|
116
|
+
this.set = allowedAuthorities(this.port, labels);
|
|
117
|
+
this.local = new Set(loopbackAuthorities(this.port, labels));
|
|
118
|
+
for (const p of this.extraPorts) {
|
|
119
|
+
for (const a of loopbackAuthorities(p, labels)) { this.set.add(a); this.local.add(a); }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Returns true when the named origin actually changed.
|
|
124
|
+
setHandle(handle) {
|
|
125
|
+
const label = hostLabel(handle);
|
|
126
|
+
if (label === this.label) return false;
|
|
127
|
+
this.label = label;
|
|
128
|
+
this.rebuild();
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
labels() { return this.label ? [this.label] : []; }
|
|
133
|
+
|
|
134
|
+
has(authority) { return this.set.has(authority); }
|
|
135
|
+
|
|
136
|
+
// Machine-local, ignoring AP_ALLOWED_HOSTS: an agent deliberately exposed
|
|
137
|
+
// under a tailnet name answers the fediverse there, but must not offer
|
|
138
|
+
// account creation or config editing over it.
|
|
139
|
+
isLocal(host) { return this.local.has(String(host || '').toLowerCase()); }
|
|
140
|
+
|
|
141
|
+
// The Host header says which authority the client asked for; the socket says
|
|
142
|
+
// where it came from. A local-only route wants both to agree. Neither
|
|
143
|
+
// survives a same-host proxy on its own — exposureProblem is what covers
|
|
144
|
+
// that case — but requiring both costs nothing and closes the plain forgery.
|
|
145
|
+
isLocalRequest(req) {
|
|
146
|
+
return isLoopbackSocket(req) && this.isLocal(req?.headers?.host);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The authority set for an identity served by somebody else's server: one
|
|
151
|
+
// origin, the pod's own, and no notion of "this machine" at all. Nothing is
|
|
152
|
+
// local here — a request from the next room and one from the far side of the
|
|
153
|
+
// world arrive the same way — so the routes that were local-only are guarded
|
|
154
|
+
// by the door's secret instead.
|
|
155
|
+
export class FixedAuthorities {
|
|
156
|
+
constructor(origin) {
|
|
157
|
+
const u = new URL(origin);
|
|
158
|
+
this.host = u.host.toLowerCase();
|
|
159
|
+
this.port = Number(u.port) || (u.protocol === 'https:' ? 443 : 80);
|
|
160
|
+
this.secure = u.protocol === 'https:';
|
|
161
|
+
this.label = null;
|
|
162
|
+
this.set = new Set([this.host]);
|
|
163
|
+
this.extraPorts = [];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
rebuild() { /* one origin, fixed at construction */ }
|
|
167
|
+
|
|
168
|
+
setHandle() { return false; } // the origin does not follow the handle
|
|
169
|
+
|
|
170
|
+
labels() { return []; }
|
|
171
|
+
|
|
172
|
+
has(authority) { return this.set.has(String(authority || '').toLowerCase()); }
|
|
173
|
+
|
|
174
|
+
isLocal() { return false; }
|
|
175
|
+
|
|
176
|
+
isLocalRequest() { return false; }
|
|
177
|
+
|
|
178
|
+
// What a page here may open a streaming socket to. Built from the pod's own
|
|
179
|
+
// origin: the loopback list would leave the socket refused by the page's own
|
|
180
|
+
// content-security-policy, with nothing in the console to say why.
|
|
181
|
+
wsAuthorities() { return [`${this.secure ? 'wss' : 'ws'}://${this.host}`]; }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const authorityOf = (originHeader) => {
|
|
185
|
+
try { return new URL(originHeader).host.toLowerCase(); } catch { return null; }
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// Returns null when the request is acceptable, else a reason string.
|
|
189
|
+
// `ignoreOrigin` keeps the Host check (what stops DNS rebinding) while letting
|
|
190
|
+
// a foreign Origin through — the bearer-gated API answers any origin the way
|
|
191
|
+
// a Mastodon server does, and a websocket with a live token likewise.
|
|
192
|
+
export function checkRequest(req, allowed, { ignoreOrigin = false } = {}) {
|
|
193
|
+
const host = String(req.headers.host || '').toLowerCase();
|
|
194
|
+
if (!host || !allowed.has(host)) return `unexpected Host "${host || '(none)'}"`;
|
|
195
|
+
if (ignoreOrigin) return null;
|
|
196
|
+
const origin = req.headers.origin;
|
|
197
|
+
// 'null' is what a sandboxed/file: page sends — never trusted here.
|
|
198
|
+
if (origin && origin !== 'null') {
|
|
199
|
+
const oh = authorityOf(origin);
|
|
200
|
+
if (!oh || !allowed.has(oh)) return `cross-origin request from "${origin}"`;
|
|
201
|
+
} else if (origin === 'null') {
|
|
202
|
+
return 'opaque origin';
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Same-site navigations and typed URLs only — blocks a visited page from
|
|
208
|
+
// driving a top-level navigation into an endpoint that mints credentials.
|
|
209
|
+
// Absent header (older browsers, curl) is allowed: Host/Origin still apply.
|
|
210
|
+
export function isCrossSiteNavigation(req) {
|
|
211
|
+
const site = req.headers['sec-fetch-site'];
|
|
212
|
+
return site === 'cross-site';
|
|
213
|
+
}
|
package/lib/home.mjs
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Where an install keeps its credential, signing keys, pidfile and log.
|
|
2
|
+
//
|
|
3
|
+
// The root was `~/.activitypod`, then `~/.solid-activitypub`, before the
|
|
4
|
+
// rename to fedipod. An install that already has one keeps using it: that
|
|
5
|
+
// directory holds the credential and the private key, and moving those is the
|
|
6
|
+
// owner's decision rather than an upgrade's side effect. `fedipod home
|
|
7
|
+
// --to <dir>` is how you take the new name when you want it.
|
|
8
|
+
//
|
|
9
|
+
// Resolve ONCE and derive `profiles/` from the same answer. Deciding the two
|
|
10
|
+
// separately is how `--profile solo` ends up looking in a different tree from
|
|
11
|
+
// the default agent.
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
export const CURRENT_ROOT = '.fedipod';
|
|
19
|
+
export const LEGACY_ROOTS = ['.solid-activitypub', '.activitypod']; // most recent first
|
|
20
|
+
|
|
21
|
+
// Existing install wins over the new name; a fresh one gets the new name.
|
|
22
|
+
export function apRoot(homedir = os.homedir()) {
|
|
23
|
+
const current = path.join(homedir, CURRENT_ROOT);
|
|
24
|
+
if (fs.existsSync(current)) return current;
|
|
25
|
+
for (const name of LEGACY_ROOTS) {
|
|
26
|
+
const legacy = path.join(homedir, name);
|
|
27
|
+
if (fs.existsSync(legacy)) return legacy;
|
|
28
|
+
}
|
|
29
|
+
return current;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const isLegacyRoot = (root) => LEGACY_ROOTS.includes(path.basename(root));
|
|
33
|
+
|
|
34
|
+
// Write one of this home's files without a window in which it is half there.
|
|
35
|
+
//
|
|
36
|
+
// lib/storage.mjs goes to the trouble of tmp+rename for state documents that
|
|
37
|
+
// its own header calls rebuildable from the pod. The files here are the ones
|
|
38
|
+
// that are NOT: keys.json is the private key remote servers have cached, and
|
|
39
|
+
// credential.json holds a secret a Solid server mints once and will not mint
|
|
40
|
+
// again. A plain writeFileSync truncates first, so a crash, a power loss or a
|
|
41
|
+
// full disk between truncate and write leaves an empty or half-written file
|
|
42
|
+
// where the identity used to be.
|
|
43
|
+
//
|
|
44
|
+
// rename(2) is atomic on POSIX, so a reader — or a backup running at the wrong
|
|
45
|
+
// moment — sees either the old file or the new one. fsync before it, so the
|
|
46
|
+
// content is on the platter and not merely in the page cache when the rename
|
|
47
|
+
// lands. Windows has no atomic rename over an existing file; there the copy
|
|
48
|
+
// path is still better than a truncate, and this is a best effort by design.
|
|
49
|
+
export function writeFileAtomic(file, body, { mode = 0o600 } = {}) {
|
|
50
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
51
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
52
|
+
const fd = fs.openSync(tmp, 'w', mode);
|
|
53
|
+
try {
|
|
54
|
+
fs.writeFileSync(fd, body);
|
|
55
|
+
fs.fsyncSync(fd);
|
|
56
|
+
} finally {
|
|
57
|
+
fs.closeSync(fd);
|
|
58
|
+
}
|
|
59
|
+
fs.renameSync(tmp, file);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The same, for the JSON these files all happen to be.
|
|
63
|
+
export function writeJsonAtomic(file, obj, opts) {
|
|
64
|
+
writeFileAtomic(file, JSON.stringify(obj, null, 2) + '\n', opts);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const profilesDir = (root) => path.join(root, 'profiles');
|
|
68
|
+
|
|
69
|
+
// The root an arbitrary home belongs to. `<root>/profiles/<name>` is a profile;
|
|
70
|
+
// anything else is its own root — so a custom AP_HOME reports no neighbours,
|
|
71
|
+
// which is the truth rather than a special case.
|
|
72
|
+
export function rootOf(home) {
|
|
73
|
+
const resolved = path.resolve(home);
|
|
74
|
+
const parent = path.dirname(resolved);
|
|
75
|
+
return path.basename(parent) === 'profiles' ? path.dirname(parent) : resolved;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Every identity under a root — all of them under `profiles/`, none of them the
|
|
79
|
+
// root itself. Callers read `agent.json` from these — a port and a handle.
|
|
80
|
+
// Nothing here, and nothing that uses it, opens a sibling's credential or keys.
|
|
81
|
+
//
|
|
82
|
+
// The root used to BE one of these, and had no name, so this function invented
|
|
83
|
+
// `(default)` for it at display time. That was the tell: a thing you have to
|
|
84
|
+
// name when you show it does not have an identity, it has a position. It also
|
|
85
|
+
// meant one identity's home contained all the others — back it up and you
|
|
86
|
+
// backed up everyone; delete it and you deleted everyone.
|
|
87
|
+
export function identityHomes(root) {
|
|
88
|
+
const homes = [];
|
|
89
|
+
try {
|
|
90
|
+
for (const name of fs.readdirSync(profilesDir(root)).sort()) {
|
|
91
|
+
const dir = path.join(profilesDir(root), name);
|
|
92
|
+
if (fs.statSync(dir).isDirectory()) homes.push({ name, dir });
|
|
93
|
+
}
|
|
94
|
+
} catch { /* no identities yet */ }
|
|
95
|
+
return homes;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The root's own record. Today it holds one thing: which identity was last
|
|
99
|
+
// started, which is what a plain command means afterwards. Nobody sets it —
|
|
100
|
+
// using an identity IS setting it, so there is no default to configure and no
|
|
101
|
+
// way for the configured answer to drift from the one you actually work in.
|
|
102
|
+
export const ROOT_FILE = 'root.json';
|
|
103
|
+
|
|
104
|
+
export function readRoot(root) {
|
|
105
|
+
try { return JSON.parse(fs.readFileSync(path.join(root, ROOT_FILE), 'utf8')) || {}; }
|
|
106
|
+
catch { return {}; }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function writeRoot(root, fields) {
|
|
110
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
111
|
+
const rec = { ...readRoot(root), ...fields };
|
|
112
|
+
writeJsonAtomic(path.join(root, ROOT_FILE), rec);
|
|
113
|
+
return rec;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Which identity you get with no `--profile`: the last one started. Failing
|
|
117
|
+
// that, one identity is unambiguous and needs no ceremony. Two, neither ever
|
|
118
|
+
// started, is a genuine question — and the answer is to ask rather than pick,
|
|
119
|
+
// because picking silently would be picking someone's fediverse account.
|
|
120
|
+
export function defaultProfile(root) {
|
|
121
|
+
const named = readRoot(root).default;
|
|
122
|
+
// Only a home holding a credential counts. A setup that was abandoned leaves a
|
|
123
|
+
// directory behind, and letting that become the default would answer "which
|
|
124
|
+
// identity" with one that does not exist yet.
|
|
125
|
+
const real = identityHomes(root).filter(h => fs.existsSync(path.join(h.dir, 'credential.json')));
|
|
126
|
+
if (named && real.some(h => h.name === named)) return named;
|
|
127
|
+
if (named) return { missing: named };
|
|
128
|
+
return real.length === 1 ? real[0].name : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const profileHome = (root, name) => path.join(profilesDir(root), name);
|
|
132
|
+
|
|
133
|
+
// A root from before every identity moved under profiles/. Its presence is what
|
|
134
|
+
// every command checks to tell you to migrate, rather than quietly doing it —
|
|
135
|
+
// this directory holds a private key.
|
|
136
|
+
export const rootHoldsIdentity = (root) => fs.existsSync(path.join(root, 'credential.json'));
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
// For DISPLAY only: `~/.fedipod` reads at a glance where
|
|
140
|
+
// `/home/jeff/.fedipod` has to be parsed, and the home directory is
|
|
141
|
+
// the least interesting part of every path this prints. A `file:` URL is a path
|
|
142
|
+
// wearing a scheme, so it is shown as the path — which is also what you would
|
|
143
|
+
// type, since `state --to` takes either.
|
|
144
|
+
//
|
|
145
|
+
// Never used to build a path. `~` is the shell's, not the filesystem's, and
|
|
146
|
+
// anything that resolves one must expand it first.
|
|
147
|
+
export function tildify(p, homedir = os.homedir()) {
|
|
148
|
+
if (!p) return p;
|
|
149
|
+
let s = String(p);
|
|
150
|
+
if (s.startsWith('file://')) { try { s = fileURLToPath(s); } catch { return s; } }
|
|
151
|
+
if (s === homedir) return '~';
|
|
152
|
+
return s.startsWith(homedir + path.sep) ? '~' + s.slice(homedir.length) : s;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Using an identity is what makes it the default. Called when an agent starts
|
|
156
|
+
// for one — not on every read, or `--profile other status` would quietly move
|
|
157
|
+
// the machine's idea of "you" while only asking a question.
|
|
158
|
+
export function recordLastUsed(root, name) {
|
|
159
|
+
if (!root || !name || readRoot(root).default === name) return;
|
|
160
|
+
try { writeRoot(root, { default: name, at: new Date().toISOString() }); } catch { /* not fatal */ }
|
|
161
|
+
}
|
package/lib/httpsig.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// httpsig.mjs — inbound HTTP-Signature verification, and the verification
|
|
2
|
+
// receipt that carries the outcome from wherever the verify happened (an
|
|
3
|
+
// always-on gateway, or the agent's own endpoint when it is publicly reachable)
|
|
4
|
+
// into the drain, which cannot see the delivery's headers.
|
|
5
|
+
//
|
|
6
|
+
// We SIGN outbound with Fedify draft-cavage (lib/deliver.mjs); we VERIFY with
|
|
7
|
+
// the same library, so the signing-string reconstruction can never drift out
|
|
8
|
+
// of step with what we produce. The one thing Fedify must be prevented from
|
|
9
|
+
// doing on its own is fetching an attacker-named key over an unguarded socket
|
|
10
|
+
// — so the key deref is injected as a loader that goes through safefetch.
|
|
11
|
+
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import { verifyRequest } from '@fedify/fedify/sig';
|
|
14
|
+
import { assertPublicUrl, safeFetch, readCapped } from './safefetch.mjs';
|
|
15
|
+
|
|
16
|
+
// An SSRF-safe JSON-LD document loader for Fedify's key fetch. The keyId in a
|
|
17
|
+
// delivery is chosen by the sender, so every deref it triggers is untrusted
|
|
18
|
+
// input and must pass the same address policy as the rest of our outbound
|
|
19
|
+
// path. `skipActor` forces a network read past any cache, for the one refetch
|
|
20
|
+
// a rotated key needs.
|
|
21
|
+
export function makeSafeLoader({ getActors = null, fetchImpl = fetch } = {}) {
|
|
22
|
+
return async (url) => {
|
|
23
|
+
// A cached actor document short-circuits the network entirely — and it was
|
|
24
|
+
// only cached after its own origin vouched for its id (intake.fetchAP).
|
|
25
|
+
if (getActors) {
|
|
26
|
+
const hit = getActors()[url];
|
|
27
|
+
if (hit) return { document: hit, documentUrl: url, contextUrl: null };
|
|
28
|
+
}
|
|
29
|
+
await assertPublicUrl(url);
|
|
30
|
+
const res = await safeFetch(url, {
|
|
31
|
+
headers: { accept: 'application/activity+json, application/ld+json' },
|
|
32
|
+
}, fetchImpl);
|
|
33
|
+
if (res.status >= 400) throw new Error(`key fetch ${url} → ${res.status}`);
|
|
34
|
+
const document = JSON.parse(await readCapped(res));
|
|
35
|
+
return { document, documentUrl: url, contextUrl: null };
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Verify a delivery's signature. `request` is a WHATWG Request (the gateway
|
|
40
|
+
// has one natively; an agent-side endpoint builds one from its req). Returns a
|
|
41
|
+
// normalized result both the gateway and the drain understand — never throws
|
|
42
|
+
// on a bad signature, because "unverified" is a routine outcome, not an error.
|
|
43
|
+
export async function verifyHttpSignature(request, { documentLoader, keyCache, timeWindow } = {}) {
|
|
44
|
+
const hadSig = request.headers.get('signature') != null;
|
|
45
|
+
let key = null;
|
|
46
|
+
try {
|
|
47
|
+
key = await verifyRequest(request, {
|
|
48
|
+
documentLoader,
|
|
49
|
+
...(keyCache ? { keyCache } : {}),
|
|
50
|
+
timeWindow: timeWindow ?? { hours: 1 },
|
|
51
|
+
});
|
|
52
|
+
} catch {
|
|
53
|
+
key = null; // a loader that refused (SSRF, 401 from a secure-mode pod) → unverified
|
|
54
|
+
}
|
|
55
|
+
if (key) {
|
|
56
|
+
return {
|
|
57
|
+
verified: true, method: 'draft-cavage',
|
|
58
|
+
keyId: key.id?.href ?? null, actor: key.ownerId?.href ?? null, reason: null,
|
|
59
|
+
checks: { signature: true, digest: true, dateSkew: true, keyFetched: true },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
verified: false, method: hadSig ? 'draft-cavage' : 'none',
|
|
64
|
+
keyId: null, actor: null,
|
|
65
|
+
// The distinction the receipt needs: a forgery (present but invalid) is
|
|
66
|
+
// dropped at the edge; an absent or unfetchable-key signature degrades to
|
|
67
|
+
// buffered-unverified, exactly as an install with no gateway behaves.
|
|
68
|
+
reason: hadSig ? 'bad-signature-or-key-unfetchable' : 'no-signature',
|
|
69
|
+
checks: { signature: false },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The receipt: what the door concluded, HMAC-stamped so the drain can trust it
|
|
74
|
+
// even while the inbox is still public-Append (a forger without the secret
|
|
75
|
+
// cannot mint `verified: true`). The HMAC is over the receipt minus the hmac
|
|
76
|
+
// field itself, with sorted keys so gateway and drain canonicalize alike.
|
|
77
|
+
function canonical(obj) {
|
|
78
|
+
return JSON.stringify(obj, Object.keys(obj).filter(k => k !== 'hmac').sort());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function makeReceipt(result, { gateway = null } = {}) {
|
|
82
|
+
return {
|
|
83
|
+
v: 1,
|
|
84
|
+
verified: !!result.verified,
|
|
85
|
+
method: result.method,
|
|
86
|
+
keyId: result.keyId,
|
|
87
|
+
actor: result.actor,
|
|
88
|
+
checks: result.checks,
|
|
89
|
+
reason: result.reason,
|
|
90
|
+
gateway,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function signReceipt(receipt, secret) {
|
|
95
|
+
const hmac = crypto.createHmac('sha256', secret).update(canonical(receipt)).digest('base64');
|
|
96
|
+
return { ...receipt, hmac };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// True only when the receipt carries a valid HMAC for `secret`. A missing
|
|
100
|
+
// secret, a missing hmac, or a mismatch all read as "not ours" → the drain
|
|
101
|
+
// treats the receipt as absent (today's unverified behavior). timingSafeEqual
|
|
102
|
+
// over equal-length buffers; unequal lengths are a straight mismatch.
|
|
103
|
+
export function verifyReceipt(receipt, secret) {
|
|
104
|
+
if (!receipt || typeof receipt !== 'object' || !receipt.hmac || !secret) return false;
|
|
105
|
+
const expected = crypto.createHmac('sha256', secret).update(canonical(receipt)).digest();
|
|
106
|
+
let given;
|
|
107
|
+
try { given = Buffer.from(receipt.hmac, 'base64'); } catch { return false; }
|
|
108
|
+
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
|
|
109
|
+
}
|