termdock 1.4.174 → 1.4.176
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/.desktop-dist/main.js +154 -7
- package/dist/client/assets/App-BXYZyI_k.js +150 -0
- package/dist/client/assets/App-BXYZyI_k.js.br +0 -0
- package/dist/client/assets/App-BXYZyI_k.js.gz +0 -0
- package/dist/client/assets/ChangeWalkthroughPanel-DeVtCf73.js +2 -0
- package/dist/client/assets/ChangeWalkthroughPanel-DeVtCf73.js.br +0 -0
- package/dist/client/assets/ChangeWalkthroughPanel-DeVtCf73.js.gz +0 -0
- package/dist/client/assets/DagPlayground-ZIqLZoMo.js +1 -0
- package/dist/client/assets/DagPlayground-ZIqLZoMo.js.br +0 -0
- package/dist/client/assets/DagPlayground-ZIqLZoMo.js.gz +0 -0
- package/dist/client/assets/{DiffLab-CdbPsjKS.js → DiffLab-CSMf8So2.js} +2 -2
- package/dist/client/assets/DiffLab-CSMf8So2.js.br +0 -0
- package/dist/client/assets/DiffLab-CSMf8So2.js.gz +0 -0
- package/dist/client/assets/DiffReview-CE5lW1zQ.js +1 -0
- package/dist/client/assets/DiffReview-CE5lW1zQ.js.br +0 -0
- package/dist/client/assets/DiffReview-CE5lW1zQ.js.gz +0 -0
- package/dist/client/assets/DiffReviewLab-CpUW75fZ.js +2 -0
- package/dist/client/assets/DiffReviewLab-CpUW75fZ.js.br +0 -0
- package/dist/client/assets/DiffReviewLab-CpUW75fZ.js.gz +0 -0
- package/dist/client/assets/{DiffViewer-hdxcM9yF.js → DiffViewer-BomvZ4kq.js} +5 -5
- package/dist/client/assets/DiffViewer-BomvZ4kq.js.br +0 -0
- package/dist/client/assets/DiffViewer-BomvZ4kq.js.gz +0 -0
- package/dist/client/assets/{ModelPreview-BZ454pgM.js → ModelPreview-BWdZFyhh.js} +3 -3
- package/dist/client/assets/ModelPreview-BWdZFyhh.js.br +0 -0
- package/dist/client/assets/ModelPreview-BWdZFyhh.js.gz +0 -0
- package/dist/client/assets/RightSidebar-CYVwFbOc.js +79 -0
- package/dist/client/assets/RightSidebar-CYVwFbOc.js.br +0 -0
- package/dist/client/assets/RightSidebar-CYVwFbOc.js.gz +0 -0
- package/dist/client/assets/{abnfDiagram-VCTEODGH-C185Omck.js → abnfDiagram-VCTEODGH-BIPsLStE.js} +1 -1
- package/dist/client/assets/abnfDiagram-VCTEODGH-BIPsLStE.js.br +0 -0
- package/dist/client/assets/abnfDiagram-VCTEODGH-BIPsLStE.js.gz +0 -0
- package/dist/client/assets/app-D6KzR8B3.js +34 -0
- package/dist/client/assets/app-D6KzR8B3.js.br +0 -0
- package/dist/client/assets/app-D6KzR8B3.js.gz +0 -0
- package/dist/client/assets/{arc-DNt-DTCl.js → arc-DzeT6hBj.js} +1 -1
- package/dist/client/assets/arc-DzeT6hBj.js.br +0 -0
- package/dist/client/assets/arc-DzeT6hBj.js.gz +0 -0
- package/dist/client/assets/{architectureDiagram-5GKGNRK7-C5Nwm_Qz.js → architectureDiagram-5GKGNRK7-B1qsxd_5.js} +1 -1
- package/dist/client/assets/architectureDiagram-5GKGNRK7-B1qsxd_5.js.br +0 -0
- package/dist/client/assets/architectureDiagram-5GKGNRK7-B1qsxd_5.js.gz +0 -0
- package/dist/client/assets/{blockDiagram-I7D4REHJ-C5aR_7V9.js → blockDiagram-I7D4REHJ-NLsa29Jp.js} +1 -1
- package/dist/client/assets/blockDiagram-I7D4REHJ-NLsa29Jp.js.br +0 -0
- package/dist/client/assets/blockDiagram-I7D4REHJ-NLsa29Jp.js.gz +0 -0
- package/dist/client/assets/{c4Diagram-7LVT6UL2-DjV6ZNwd.js → c4Diagram-7LVT6UL2-DBAUvou3.js} +1 -1
- package/dist/client/assets/c4Diagram-7LVT6UL2-DBAUvou3.js.br +0 -0
- package/dist/client/assets/c4Diagram-7LVT6UL2-DBAUvou3.js.gz +0 -0
- package/dist/client/assets/channel-BhwBAWhq.js +1 -0
- package/dist/client/assets/{chunk-2Q5K7J3B-Ct7nd4cL.js → chunk-2Q5K7J3B-BsVAXGLa.js} +1 -1
- package/dist/client/assets/{chunk-5VM5RSS4-CXem4UYP.js → chunk-5VM5RSS4-BKtILsst.js} +1 -1
- package/dist/client/assets/{chunk-F27PBJKO-DyMNEawE.js → chunk-F27PBJKO-Dn9zcQZG.js} +1 -1
- package/dist/client/assets/chunk-F27PBJKO-Dn9zcQZG.js.br +0 -0
- package/dist/client/assets/chunk-F27PBJKO-Dn9zcQZG.js.gz +0 -0
- package/dist/client/assets/{chunk-IMKFNOWR-BsW7iIrh.js → chunk-IMKFNOWR-MVtVQeTr.js} +1 -1
- package/dist/client/assets/chunk-IMKFNOWR-MVtVQeTr.js.br +0 -0
- package/dist/client/assets/{chunk-IMKFNOWR-BsW7iIrh.js.gz → chunk-IMKFNOWR-MVtVQeTr.js.gz} +0 -0
- package/dist/client/assets/{chunk-JWPE2WC7-EO8E1q1J.js → chunk-JWPE2WC7-DliIIMe7.js} +1 -1
- package/dist/client/assets/{chunk-POPQ4Y6H-0One8T9x.js → chunk-POPQ4Y6H-XBSus3IC.js} +1 -1
- package/dist/client/assets/{chunk-SVP7TREG-CDu41dkj.js → chunk-SVP7TREG-wleSQcJR.js} +1 -1
- package/dist/client/assets/chunk-SVP7TREG-wleSQcJR.js.br +0 -0
- package/dist/client/assets/chunk-SVP7TREG-wleSQcJR.js.gz +0 -0
- package/dist/client/assets/{chunk-TICWLB2K-BACjgX9v.js → chunk-TICWLB2K-Dq6LdswN.js} +1 -1
- package/dist/client/assets/chunk-TICWLB2K-Dq6LdswN.js.br +0 -0
- package/dist/client/assets/chunk-TICWLB2K-Dq6LdswN.js.gz +0 -0
- package/dist/client/assets/{chunk-XXDRQBXY-BJWowF6E.js → chunk-XXDRQBXY-xafyKlM-.js} +1 -1
- package/dist/client/assets/classDiagram-ZZMXUADV-slQB0ns2.js +1 -0
- package/dist/client/assets/classDiagram-v2-VYDZK3BY-slQB0ns2.js +1 -0
- package/dist/client/assets/{cose-bilkent-JH36ORCC-WTwHOCdm.js → cose-bilkent-JH36ORCC-BR7Ohnm_.js} +1 -1
- package/dist/client/assets/cose-bilkent-JH36ORCC-BR7Ohnm_.js.br +0 -0
- package/dist/client/assets/cose-bilkent-JH36ORCC-BR7Ohnm_.js.gz +0 -0
- package/dist/client/assets/{cynefin-OW5HDTMX-D4y8TkaO.js → cynefin-OW5HDTMX-CmPjQgER.js} +1 -1
- package/dist/client/assets/cynefin-OW5HDTMX-CmPjQgER.js.br +0 -0
- package/dist/client/assets/cynefin-OW5HDTMX-CmPjQgER.js.gz +0 -0
- package/dist/client/assets/{cynefinDiagram-5FMLGOSQ-BzN8Ggk_.js → cynefinDiagram-5FMLGOSQ-CFyR9RvM.js} +1 -1
- package/dist/client/assets/cynefinDiagram-5FMLGOSQ-CFyR9RvM.js.br +0 -0
- package/dist/client/assets/cynefinDiagram-5FMLGOSQ-CFyR9RvM.js.gz +0 -0
- package/dist/client/assets/{dagre-GXQ25YYZ-CwMYtt5E.js → dagre-GXQ25YYZ-D_RVMRO1.js} +1 -1
- package/dist/client/assets/dagre-GXQ25YYZ-D_RVMRO1.js.br +0 -0
- package/dist/client/assets/dagre-GXQ25YYZ-D_RVMRO1.js.gz +0 -0
- package/dist/client/assets/{diagram-S7CK7UJ4-BnTeHiAb.js → diagram-S7CK7UJ4-6fr-ZoPj.js} +1 -1
- package/dist/client/assets/diagram-S7CK7UJ4-6fr-ZoPj.js.br +0 -0
- package/dist/client/assets/diagram-S7CK7UJ4-6fr-ZoPj.js.gz +0 -0
- package/dist/client/assets/{diagram-UQ7AKVKN-BNlh251g.js → diagram-UQ7AKVKN-CgwiUtz0.js} +1 -1
- package/dist/client/assets/diagram-UQ7AKVKN-CgwiUtz0.js.br +0 -0
- package/dist/client/assets/diagram-UQ7AKVKN-CgwiUtz0.js.gz +0 -0
- package/dist/client/assets/{diagram-VSXAHHWV-B5FftXkQ.js → diagram-VSXAHHWV-CLxzTB9k.js} +1 -1
- package/dist/client/assets/diagram-VSXAHHWV-CLxzTB9k.js.br +0 -0
- package/dist/client/assets/diagram-VSXAHHWV-CLxzTB9k.js.gz +0 -0
- package/dist/client/assets/{diagram-VX7I27RA-C51ZNXR2.js → diagram-VX7I27RA-DjxDAvdZ.js} +1 -1
- package/dist/client/assets/diagram-VX7I27RA-DjxDAvdZ.js.br +0 -0
- package/dist/client/assets/diagram-VX7I27RA-DjxDAvdZ.js.gz +0 -0
- package/dist/client/assets/{diagram-Z3DM3KII-CXoZH2qf.js → diagram-Z3DM3KII-CDPOjiz4.js} +1 -1
- package/dist/client/assets/diagram-Z3DM3KII-CDPOjiz4.js.br +0 -0
- package/dist/client/assets/diagram-Z3DM3KII-CDPOjiz4.js.gz +0 -0
- package/dist/client/assets/{ebnfDiagram-PWID7BFC-C7YFLlx4.js → ebnfDiagram-PWID7BFC-BLbwyF1O.js} +1 -1
- package/dist/client/assets/ebnfDiagram-PWID7BFC-BLbwyF1O.js.br +0 -0
- package/dist/client/assets/ebnfDiagram-PWID7BFC-BLbwyF1O.js.gz +0 -0
- package/dist/client/assets/{erDiagram-RLTQ6QDP-BNQlS7-g.js → erDiagram-RLTQ6QDP-DbjtZnJx.js} +1 -1
- package/dist/client/assets/erDiagram-RLTQ6QDP-DbjtZnJx.js.br +0 -0
- package/dist/client/assets/erDiagram-RLTQ6QDP-DbjtZnJx.js.gz +0 -0
- package/dist/client/assets/{flowDiagram-HODETNUW-DIQXZ06I.js → flowDiagram-HODETNUW-BirJInAN.js} +1 -1
- package/dist/client/assets/flowDiagram-HODETNUW-BirJInAN.js.br +0 -0
- package/dist/client/assets/flowDiagram-HODETNUW-BirJInAN.js.gz +0 -0
- package/dist/client/assets/{ganttDiagram-EL5Y4UJY-BaJQfOqD.js → ganttDiagram-EL5Y4UJY-d6Q9vSbv.js} +1 -1
- package/dist/client/assets/ganttDiagram-EL5Y4UJY-d6Q9vSbv.js.br +0 -0
- package/dist/client/assets/ganttDiagram-EL5Y4UJY-d6Q9vSbv.js.gz +0 -0
- package/dist/client/assets/gestureArbiter-C2J0D0kV.css +1 -0
- package/dist/client/assets/gestureArbiter-C2J0D0kV.css.br +0 -0
- package/dist/client/assets/gestureArbiter-C2J0D0kV.css.gz +0 -0
- package/dist/client/assets/gestureArbiter-D1SNS_gK.js +1 -0
- package/dist/client/assets/gestureArbiter-D1SNS_gK.js.br +0 -0
- package/dist/client/assets/gestureArbiter-D1SNS_gK.js.gz +0 -0
- package/dist/client/assets/{gitGraphDiagram-WWUBYQGX-tQ4E6pwN.js → gitGraphDiagram-WWUBYQGX-BWBoIeS6.js} +1 -1
- package/dist/client/assets/gitGraphDiagram-WWUBYQGX-BWBoIeS6.js.br +0 -0
- package/dist/client/assets/gitGraphDiagram-WWUBYQGX-BWBoIeS6.js.gz +0 -0
- package/dist/client/assets/{index-DlxiaPv1.css → index-DUI8KRf6.css} +1 -1
- package/dist/client/assets/index-DUI8KRf6.css.br +0 -0
- package/dist/client/assets/index-DUI8KRf6.css.gz +0 -0
- package/dist/client/assets/{infoDiagram-27XIBGKW-DTOUeQrN.js → infoDiagram-27XIBGKW-BH4AynkM.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-5VMMS53U-8sBi-gla.js → ishikawaDiagram-5VMMS53U-BQw90AKR.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-5VMMS53U-8sBi-gla.js.br → ishikawaDiagram-5VMMS53U-BQw90AKR.js.br} +0 -0
- package/dist/client/assets/ishikawaDiagram-5VMMS53U-BQw90AKR.js.gz +0 -0
- package/dist/client/assets/{journeyDiagram-3NMN7TZE-ZwikziWh.js → journeyDiagram-3NMN7TZE-DISkQocf.js} +1 -1
- package/dist/client/assets/journeyDiagram-3NMN7TZE-DISkQocf.js.br +0 -0
- package/dist/client/assets/{journeyDiagram-3NMN7TZE-ZwikziWh.js.gz → journeyDiagram-3NMN7TZE-DISkQocf.js.gz} +0 -0
- package/dist/client/assets/{kanban-definition-UXKFOSKX-wjuw2jQ4.js → kanban-definition-UXKFOSKX-Qg2LyEVy.js} +1 -1
- package/dist/client/assets/kanban-definition-UXKFOSKX-Qg2LyEVy.js.br +0 -0
- package/dist/client/assets/kanban-definition-UXKFOSKX-Qg2LyEVy.js.gz +0 -0
- package/dist/client/assets/{layout-nASMIj-E.js → layout-Cd7-4_Vr.js} +1 -1
- package/dist/client/assets/layout-Cd7-4_Vr.js.br +0 -0
- package/dist/client/assets/layout-Cd7-4_Vr.js.gz +0 -0
- package/dist/client/assets/{linear-pGaQeqW5.js → linear-DFTi2fTn.js} +1 -1
- package/dist/client/assets/linear-DFTi2fTn.js.br +0 -0
- package/dist/client/assets/linear-DFTi2fTn.js.gz +0 -0
- package/dist/client/assets/{mermaid.core-CKEBWbQz.js → mermaid.core-2v726lUU.js} +5 -5
- package/dist/client/assets/mermaid.core-2v726lUU.js.br +0 -0
- package/dist/client/assets/mermaid.core-2v726lUU.js.gz +0 -0
- package/dist/client/assets/{mindmap-definition-YA3MSWOX-CBjYcnwd.js → mindmap-definition-YA3MSWOX-_FpwteCy.js} +4 -4
- package/dist/client/assets/mindmap-definition-YA3MSWOX-_FpwteCy.js.br +0 -0
- package/dist/client/assets/mindmap-definition-YA3MSWOX-_FpwteCy.js.gz +0 -0
- package/dist/client/assets/passwordBootstrap-D9Ffc7NY.js +2 -0
- package/dist/client/assets/passwordBootstrap-D9Ffc7NY.js.br +0 -0
- package/dist/client/assets/passwordBootstrap-D9Ffc7NY.js.gz +0 -0
- package/dist/client/assets/{pegDiagram-XKGWAZYB-DJqvclIp.js → pegDiagram-XKGWAZYB-DLFf-HmZ.js} +1 -1
- package/dist/client/assets/pegDiagram-XKGWAZYB-DLFf-HmZ.js.br +0 -0
- package/dist/client/assets/pegDiagram-XKGWAZYB-DLFf-HmZ.js.gz +0 -0
- package/dist/client/assets/{pieDiagram-E7YTZNPT-DLV4WPwc.js → pieDiagram-E7YTZNPT-DqTnfXHG.js} +1 -1
- package/dist/client/assets/pieDiagram-E7YTZNPT-DqTnfXHG.js.br +0 -0
- package/dist/client/assets/pieDiagram-E7YTZNPT-DqTnfXHG.js.gz +0 -0
- package/dist/client/assets/{quadrantDiagram-AXDQQJYC-BN7Zpsb9.js → quadrantDiagram-AXDQQJYC-B_oBH52Q.js} +1 -1
- package/dist/client/assets/quadrantDiagram-AXDQQJYC-B_oBH52Q.js.br +0 -0
- package/dist/client/assets/quadrantDiagram-AXDQQJYC-B_oBH52Q.js.gz +0 -0
- package/dist/client/assets/{railroadDiagram-O6MQD6OU-DfHX6MUm.js → railroadDiagram-O6MQD6OU-HxFQ7Pce.js} +1 -1
- package/dist/client/assets/railroadDiagram-O6MQD6OU-HxFQ7Pce.js.br +0 -0
- package/dist/client/assets/railroadDiagram-O6MQD6OU-HxFQ7Pce.js.gz +0 -0
- package/dist/client/assets/referenceLongPress-B3NGIjvv.js +1 -0
- package/dist/client/assets/referenceLongPress-B3NGIjvv.js.br +0 -0
- package/dist/client/assets/referenceLongPress-B3NGIjvv.js.gz +0 -0
- package/dist/client/assets/{requirementDiagram-BXWQKSXE-tQVSFg_k.js → requirementDiagram-BXWQKSXE-NZdfaZFk.js} +1 -1
- package/dist/client/assets/requirementDiagram-BXWQKSXE-NZdfaZFk.js.br +0 -0
- package/dist/client/assets/requirementDiagram-BXWQKSXE-NZdfaZFk.js.gz +0 -0
- package/dist/client/assets/{sankeyDiagram-P5KCCOFB-C7JW1-Wh.js → sankeyDiagram-P5KCCOFB-BgCctv6X.js} +1 -1
- package/dist/client/assets/sankeyDiagram-P5KCCOFB-BgCctv6X.js.br +0 -0
- package/dist/client/assets/sankeyDiagram-P5KCCOFB-BgCctv6X.js.gz +0 -0
- package/dist/client/assets/{sequenceDiagram-WJ2MYXX4-BVeDiFDI.js → sequenceDiagram-WJ2MYXX4-Br42Za1R.js} +1 -1
- package/dist/client/assets/sequenceDiagram-WJ2MYXX4-Br42Za1R.js.br +0 -0
- package/dist/client/assets/sequenceDiagram-WJ2MYXX4-Br42Za1R.js.gz +0 -0
- package/dist/client/assets/{sizeCapture-INFHLROL-CBRaCpeP.js → sizeCapture-INFHLROL-FT4z4S1b.js} +1 -1
- package/dist/client/assets/sizeCapture-INFHLROL-FT4z4S1b.js.br +0 -0
- package/dist/client/assets/sizeCapture-INFHLROL-FT4z4S1b.js.gz +0 -0
- package/dist/client/assets/sparkles-BaGaevME.js +1 -0
- package/dist/client/assets/{stateDiagram-D77RDMKH-CiLpGRuf.js → stateDiagram-D77RDMKH-DD0t9f84.js} +1 -1
- package/dist/client/assets/stateDiagram-D77RDMKH-DD0t9f84.js.br +0 -0
- package/dist/client/assets/stateDiagram-D77RDMKH-DD0t9f84.js.gz +0 -0
- package/dist/client/assets/stateDiagram-v2-MP3YSRHH-BhvVVB56.js +1 -0
- package/dist/client/assets/{swimlanes-42K2YHIH-BkzVtkVY.js → swimlanes-42K2YHIH-DcC8pGLx.js} +1 -1
- package/dist/client/assets/swimlanes-42K2YHIH-DcC8pGLx.js.br +0 -0
- package/dist/client/assets/swimlanes-42K2YHIH-DcC8pGLx.js.gz +0 -0
- package/dist/client/assets/swimlanesDiagram-VR7AAH4N-DSC5lz0i.js +8 -0
- package/dist/client/assets/{timeline-definition-24CTP7MA-DKyfGITP.js → timeline-definition-24CTP7MA-DnwO_Pxd.js} +1 -1
- package/dist/client/assets/timeline-definition-24CTP7MA-DnwO_Pxd.js.br +0 -0
- package/dist/client/assets/timeline-definition-24CTP7MA-DnwO_Pxd.js.gz +0 -0
- package/dist/client/assets/triangle-alert-BCQO9Ap9.js +1 -0
- package/dist/client/assets/{vennDiagram-4TSXK5OY-CRgkkrgx.js → vennDiagram-4TSXK5OY-IVNSrZDM.js} +1 -1
- package/dist/client/assets/vennDiagram-4TSXK5OY-IVNSrZDM.js.br +0 -0
- package/dist/client/assets/vennDiagram-4TSXK5OY-IVNSrZDM.js.gz +0 -0
- package/dist/client/assets/{wardleyDiagram-VM6X3IG4-Cr7yS4CZ.js → wardleyDiagram-VM6X3IG4-BnGetaQL.js} +1 -1
- package/dist/client/assets/wardleyDiagram-VM6X3IG4-BnGetaQL.js.br +0 -0
- package/dist/client/assets/wardleyDiagram-VM6X3IG4-BnGetaQL.js.gz +0 -0
- package/dist/client/assets/{xychartDiagram-S5SC5T6Z-DkPYl6E0.js → xychartDiagram-S5SC5T6Z-C01eBN-W.js} +1 -1
- package/dist/client/assets/xychartDiagram-S5SC5T6Z-C01eBN-W.js.br +0 -0
- package/dist/client/assets/xychartDiagram-S5SC5T6Z-C01eBN-W.js.gz +0 -0
- package/dist/client/index.html +2 -2
- package/dist/client/index.html.br +0 -0
- package/dist/client/index.html.gz +0 -0
- package/dist/client/preview-shell.html +1 -0
- package/dist/client/preview-shell.js +20 -0
- package/dist/client/preview-shell.js.br +0 -0
- package/dist/client/preview-shell.js.gz +0 -0
- package/dist/client/sw-secure-api.js +132 -0
- package/dist/client/sw-secure-api.js.br +0 -0
- package/dist/client/sw-secure-api.js.gz +0 -0
- package/dist/client/sw.js +1 -1
- package/dist/client/sw.js.br +0 -0
- package/dist/client/sw.js.gz +0 -0
- package/dist/server/agent/collaborationCli.js +298 -0
- package/dist/server/agent/collaborationPrompt.js +10 -2
- package/dist/server/agent/collaborationProtocol.js +50 -0
- package/dist/server/agent/collaborationRoutes.js +86 -0
- package/dist/server/agent/collaborationStore.js +249 -21
- package/dist/server/cli.js +28 -168
- package/dist/server/entry.js +114 -51
- package/dist/server/federation/accessPolicy.js +122 -0
- package/dist/server/federation/authorization.js +226 -0
- package/dist/server/federation/bootstrapCors.js +23 -0
- package/dist/server/federation/cli.js +185 -0
- package/dist/server/federation/deviceNames.js +29 -0
- package/dist/server/federation/directRoutes.js +72 -0
- package/dist/server/federation/invitations.js +123 -0
- package/dist/server/federation/openAccess.js +25 -0
- package/dist/server/federation/originPolicy.js +19 -0
- package/dist/server/federation/packets.js +116 -0
- package/dist/server/federation/passwordBootstrap.js +86 -0
- package/dist/server/federation/passwordLoginRoutes.js +98 -0
- package/dist/server/federation/relay.js +346 -0
- package/dist/server/federation/requestContext.js +4 -0
- package/dist/server/federation/routeAccess.js +124 -0
- package/dist/server/federation/routeInvitations.js +160 -0
- package/dist/server/federation/runtime.js +537 -0
- package/dist/server/federation/secureProtocol.js +223 -0
- package/dist/server/federation/socketDuplex.js +42 -0
- package/dist/server/federation/streamLimits.js +5 -0
- package/dist/server/routes/filesystem.js +2 -1
- package/dist/server/routes/terminal.js +40 -72
- package/dist/server/utils/authProtection.js +13 -0
- package/dist/server/utils/csrfProtection.js +3 -0
- package/dist/server/utils/publicSecurity.js +8 -3
- package/package.json +10 -3
- package/runtime-manifest.json +4 -4
- package/dist/client/assets/ChangeWalkthroughPanel-hj8I8Cwy.js +0 -2
- package/dist/client/assets/ChangeWalkthroughPanel-hj8I8Cwy.js.br +0 -0
- package/dist/client/assets/ChangeWalkthroughPanel-hj8I8Cwy.js.gz +0 -0
- package/dist/client/assets/DagPlayground-BRoB8JX5.js +0 -1
- package/dist/client/assets/DagPlayground-BRoB8JX5.js.br +0 -0
- package/dist/client/assets/DagPlayground-BRoB8JX5.js.gz +0 -0
- package/dist/client/assets/DiffLab-CdbPsjKS.js.br +0 -0
- package/dist/client/assets/DiffLab-CdbPsjKS.js.gz +0 -0
- package/dist/client/assets/DiffReview-MQXjrRsW.js +0 -1
- package/dist/client/assets/DiffReview-MQXjrRsW.js.br +0 -0
- package/dist/client/assets/DiffReview-MQXjrRsW.js.gz +0 -0
- package/dist/client/assets/DiffReviewLab-C47CNvDM.js +0 -2
- package/dist/client/assets/DiffReviewLab-C47CNvDM.js.br +0 -0
- package/dist/client/assets/DiffReviewLab-C47CNvDM.js.gz +0 -0
- package/dist/client/assets/DiffViewer-hdxcM9yF.js.br +0 -0
- package/dist/client/assets/DiffViewer-hdxcM9yF.js.gz +0 -0
- package/dist/client/assets/ModelPreview-BZ454pgM.js.br +0 -0
- package/dist/client/assets/ModelPreview-BZ454pgM.js.gz +0 -0
- package/dist/client/assets/RightSidebar-i0Oq68YI.js +0 -55
- package/dist/client/assets/RightSidebar-i0Oq68YI.js.br +0 -0
- package/dist/client/assets/RightSidebar-i0Oq68YI.js.gz +0 -0
- package/dist/client/assets/abnfDiagram-VCTEODGH-C185Omck.js.br +0 -0
- package/dist/client/assets/abnfDiagram-VCTEODGH-C185Omck.js.gz +0 -0
- package/dist/client/assets/app-DAp8rVMc.js +0 -175
- package/dist/client/assets/app-DAp8rVMc.js.br +0 -0
- package/dist/client/assets/app-DAp8rVMc.js.gz +0 -0
- package/dist/client/assets/arc-DNt-DTCl.js.br +0 -0
- package/dist/client/assets/arc-DNt-DTCl.js.gz +0 -0
- package/dist/client/assets/architectureDiagram-5GKGNRK7-C5Nwm_Qz.js.br +0 -0
- package/dist/client/assets/architectureDiagram-5GKGNRK7-C5Nwm_Qz.js.gz +0 -0
- package/dist/client/assets/blockDiagram-I7D4REHJ-C5aR_7V9.js.br +0 -0
- package/dist/client/assets/blockDiagram-I7D4REHJ-C5aR_7V9.js.gz +0 -0
- package/dist/client/assets/c4Diagram-7LVT6UL2-DjV6ZNwd.js.br +0 -0
- package/dist/client/assets/c4Diagram-7LVT6UL2-DjV6ZNwd.js.gz +0 -0
- package/dist/client/assets/channel-uhLrATdy.js +0 -1
- package/dist/client/assets/chunk-F27PBJKO-DyMNEawE.js.br +0 -0
- package/dist/client/assets/chunk-F27PBJKO-DyMNEawE.js.gz +0 -0
- package/dist/client/assets/chunk-IMKFNOWR-BsW7iIrh.js.br +0 -0
- package/dist/client/assets/chunk-SVP7TREG-CDu41dkj.js.br +0 -0
- package/dist/client/assets/chunk-SVP7TREG-CDu41dkj.js.gz +0 -0
- package/dist/client/assets/chunk-TICWLB2K-BACjgX9v.js.br +0 -0
- package/dist/client/assets/chunk-TICWLB2K-BACjgX9v.js.gz +0 -0
- package/dist/client/assets/classDiagram-ZZMXUADV-CnxFfzm9.js +0 -1
- package/dist/client/assets/classDiagram-v2-VYDZK3BY-CnxFfzm9.js +0 -1
- package/dist/client/assets/cose-bilkent-JH36ORCC-WTwHOCdm.js.br +0 -0
- package/dist/client/assets/cose-bilkent-JH36ORCC-WTwHOCdm.js.gz +0 -0
- package/dist/client/assets/cynefin-OW5HDTMX-D4y8TkaO.js.br +0 -0
- package/dist/client/assets/cynefin-OW5HDTMX-D4y8TkaO.js.gz +0 -0
- package/dist/client/assets/cynefinDiagram-5FMLGOSQ-BzN8Ggk_.js.br +0 -0
- package/dist/client/assets/cynefinDiagram-5FMLGOSQ-BzN8Ggk_.js.gz +0 -0
- package/dist/client/assets/dagre-GXQ25YYZ-CwMYtt5E.js.br +0 -0
- package/dist/client/assets/dagre-GXQ25YYZ-CwMYtt5E.js.gz +0 -0
- package/dist/client/assets/diagram-S7CK7UJ4-BnTeHiAb.js.br +0 -0
- package/dist/client/assets/diagram-S7CK7UJ4-BnTeHiAb.js.gz +0 -0
- package/dist/client/assets/diagram-UQ7AKVKN-BNlh251g.js.br +0 -0
- package/dist/client/assets/diagram-UQ7AKVKN-BNlh251g.js.gz +0 -0
- package/dist/client/assets/diagram-VSXAHHWV-B5FftXkQ.js.br +0 -0
- package/dist/client/assets/diagram-VSXAHHWV-B5FftXkQ.js.gz +0 -0
- package/dist/client/assets/diagram-VX7I27RA-C51ZNXR2.js.br +0 -0
- package/dist/client/assets/diagram-VX7I27RA-C51ZNXR2.js.gz +0 -0
- package/dist/client/assets/diagram-Z3DM3KII-CXoZH2qf.js.br +0 -0
- package/dist/client/assets/diagram-Z3DM3KII-CXoZH2qf.js.gz +0 -0
- package/dist/client/assets/ebnfDiagram-PWID7BFC-C7YFLlx4.js.br +0 -0
- package/dist/client/assets/ebnfDiagram-PWID7BFC-C7YFLlx4.js.gz +0 -0
- package/dist/client/assets/erDiagram-RLTQ6QDP-BNQlS7-g.js.br +0 -0
- package/dist/client/assets/erDiagram-RLTQ6QDP-BNQlS7-g.js.gz +0 -0
- package/dist/client/assets/flowDiagram-HODETNUW-DIQXZ06I.js.br +0 -0
- package/dist/client/assets/flowDiagram-HODETNUW-DIQXZ06I.js.gz +0 -0
- package/dist/client/assets/ganttDiagram-EL5Y4UJY-BaJQfOqD.js.br +0 -0
- package/dist/client/assets/ganttDiagram-EL5Y4UJY-BaJQfOqD.js.gz +0 -0
- package/dist/client/assets/gitGraphDiagram-WWUBYQGX-tQ4E6pwN.js.br +0 -0
- package/dist/client/assets/gitGraphDiagram-WWUBYQGX-tQ4E6pwN.js.gz +0 -0
- package/dist/client/assets/index-DlxiaPv1.css.br +0 -0
- package/dist/client/assets/index-DlxiaPv1.css.gz +0 -0
- package/dist/client/assets/ishikawaDiagram-5VMMS53U-8sBi-gla.js.gz +0 -0
- package/dist/client/assets/journeyDiagram-3NMN7TZE-ZwikziWh.js.br +0 -0
- package/dist/client/assets/kanban-definition-UXKFOSKX-wjuw2jQ4.js.br +0 -0
- package/dist/client/assets/kanban-definition-UXKFOSKX-wjuw2jQ4.js.gz +0 -0
- package/dist/client/assets/layout-nASMIj-E.js.br +0 -0
- package/dist/client/assets/layout-nASMIj-E.js.gz +0 -0
- package/dist/client/assets/linear-pGaQeqW5.js.br +0 -0
- package/dist/client/assets/linear-pGaQeqW5.js.gz +0 -0
- package/dist/client/assets/mermaid.core-CKEBWbQz.js.br +0 -0
- package/dist/client/assets/mermaid.core-CKEBWbQz.js.gz +0 -0
- package/dist/client/assets/mindmap-definition-YA3MSWOX-CBjYcnwd.js.br +0 -0
- package/dist/client/assets/mindmap-definition-YA3MSWOX-CBjYcnwd.js.gz +0 -0
- package/dist/client/assets/pegDiagram-XKGWAZYB-DJqvclIp.js.br +0 -0
- package/dist/client/assets/pegDiagram-XKGWAZYB-DJqvclIp.js.gz +0 -0
- package/dist/client/assets/pieDiagram-E7YTZNPT-DLV4WPwc.js.br +0 -0
- package/dist/client/assets/pieDiagram-E7YTZNPT-DLV4WPwc.js.gz +0 -0
- package/dist/client/assets/quadrantDiagram-AXDQQJYC-BN7Zpsb9.js.br +0 -0
- package/dist/client/assets/quadrantDiagram-AXDQQJYC-BN7Zpsb9.js.gz +0 -0
- package/dist/client/assets/railroadDiagram-O6MQD6OU-DfHX6MUm.js.br +0 -0
- package/dist/client/assets/railroadDiagram-O6MQD6OU-DfHX6MUm.js.gz +0 -0
- package/dist/client/assets/requirementDiagram-BXWQKSXE-tQVSFg_k.js.br +0 -0
- package/dist/client/assets/requirementDiagram-BXWQKSXE-tQVSFg_k.js.gz +0 -0
- package/dist/client/assets/sankeyDiagram-P5KCCOFB-C7JW1-Wh.js.br +0 -0
- package/dist/client/assets/sankeyDiagram-P5KCCOFB-C7JW1-Wh.js.gz +0 -0
- package/dist/client/assets/sequenceDiagram-WJ2MYXX4-BVeDiFDI.js.br +0 -0
- package/dist/client/assets/sequenceDiagram-WJ2MYXX4-BVeDiFDI.js.gz +0 -0
- package/dist/client/assets/sizeCapture-INFHLROL-CBRaCpeP.js.br +0 -0
- package/dist/client/assets/sizeCapture-INFHLROL-CBRaCpeP.js.gz +0 -0
- package/dist/client/assets/sparkles-D0cwIvB0.js +0 -1
- package/dist/client/assets/stateDiagram-D77RDMKH-CiLpGRuf.js.br +0 -0
- package/dist/client/assets/stateDiagram-D77RDMKH-CiLpGRuf.js.gz +0 -0
- package/dist/client/assets/stateDiagram-v2-MP3YSRHH-BRbS0rv2.js +0 -1
- package/dist/client/assets/swimlanes-42K2YHIH-BkzVtkVY.js.br +0 -0
- package/dist/client/assets/swimlanes-42K2YHIH-BkzVtkVY.js.gz +0 -0
- package/dist/client/assets/swimlanesDiagram-VR7AAH4N-CeWFezkt.js +0 -8
- package/dist/client/assets/timeline-definition-24CTP7MA-DKyfGITP.js.br +0 -0
- package/dist/client/assets/timeline-definition-24CTP7MA-DKyfGITP.js.gz +0 -0
- package/dist/client/assets/vennDiagram-4TSXK5OY-CRgkkrgx.js.br +0 -0
- package/dist/client/assets/vennDiagram-4TSXK5OY-CRgkkrgx.js.gz +0 -0
- package/dist/client/assets/wardleyDiagram-VM6X3IG4-Cr7yS4CZ.js.br +0 -0
- package/dist/client/assets/wardleyDiagram-VM6X3IG4-Cr7yS4CZ.js.gz +0 -0
- package/dist/client/assets/xychartDiagram-S5SC5T6Z-DkPYl6E0.js.br +0 -0
- package/dist/client/assets/xychartDiagram-S5SC5T6Z-DkPYl6E0.js.gz +0 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import WebSocket from 'ws';
|
|
5
|
+
import QRCode from 'qrcode';
|
|
6
|
+
import { RelayClient } from './relay.js';
|
|
7
|
+
function secureEndpoint(value, endpoint) {
|
|
8
|
+
const url = new URL(value);
|
|
9
|
+
if (!['https:', 'wss:'].includes(url.protocol) || url.username || url.password || url.search || url.hash)
|
|
10
|
+
throw new Error('Federation relay URLs must use HTTPS/WSS without credentials, query or fragment');
|
|
11
|
+
if (url.pathname !== '/' && url.pathname !== endpoint)
|
|
12
|
+
throw new Error('Federation relay URL must be a service origin or the exact tunnel endpoint');
|
|
13
|
+
url.protocol = 'wss:';
|
|
14
|
+
url.pathname = endpoint;
|
|
15
|
+
return url.toString();
|
|
16
|
+
}
|
|
17
|
+
export function parseRelayConfig(value) {
|
|
18
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
19
|
+
throw new Error('Invalid relay config');
|
|
20
|
+
const v = value;
|
|
21
|
+
if (Object.keys(v).some(k => !['entryUrl', 'relayToken', 'caPath', 'targets'].includes(k)) || typeof v.entryUrl !== 'string' || typeof v.relayToken !== 'string' || v.relayToken.length < 32 || /[\r\n]/.test(v.relayToken) || !Array.isArray(v.targets) || !v.targets.length || v.targets.length > 64 || (v.caPath !== undefined && typeof v.caPath !== 'string'))
|
|
22
|
+
throw new Error('Invalid relay config fields');
|
|
23
|
+
secureEndpoint(v.entryUrl, '/api/federation/relay');
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
for (const target of v.targets) {
|
|
26
|
+
if (!target || typeof target !== 'object' || Array.isArray(target) || Object.keys(target).some(k => !['serviceId', 'url', 'caPath'].includes(k)) || typeof target.serviceId !== 'string' || !/^[a-zA-Z0-9_.:-]{1,160}$/.test(target.serviceId) || typeof target.url !== 'string' || (target.caPath !== undefined && typeof target.caPath !== 'string') || seen.has(target.serviceId))
|
|
27
|
+
throw new Error('Invalid or duplicate relay target');
|
|
28
|
+
secureEndpoint(target.url, '/api/federation/secure');
|
|
29
|
+
seen.add(target.serviceId);
|
|
30
|
+
}
|
|
31
|
+
return structuredClone(v);
|
|
32
|
+
}
|
|
33
|
+
function connect(url, ca, sockets, token, readyHandshake = false) {
|
|
34
|
+
return new Promise((accept, reject) => {
|
|
35
|
+
const ws = new WebSocket(url, { ca, rejectUnauthorized: true, followRedirects: false, handshakeTimeout: 10_000, maxPayload: 256 * 1024, ...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}) });
|
|
36
|
+
sockets.add(ws);
|
|
37
|
+
ws.once('close', () => sockets.delete(ws));
|
|
38
|
+
const failed = () => { ws.terminate(); reject(new Error('Secure relay connection failed')); };
|
|
39
|
+
ws.once('error', failed);
|
|
40
|
+
ws.once('close', failed);
|
|
41
|
+
ws.once('open', () => {
|
|
42
|
+
ws.off('error', failed);
|
|
43
|
+
ws.off('close', failed);
|
|
44
|
+
ws.on('error', () => ws.terminate());
|
|
45
|
+
if (readyHandshake)
|
|
46
|
+
void awaitReady(ws).then(() => accept(ws), reject);
|
|
47
|
+
else
|
|
48
|
+
accept(ws);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
async function awaitReady(ws) {
|
|
53
|
+
return new Promise((accept, reject) => {
|
|
54
|
+
const timer = setTimeout(() => fail(), 10_000);
|
|
55
|
+
const cleanup = () => { clearTimeout(timer); ws.off('message', ready); ws.off('close', fail); };
|
|
56
|
+
const fail = () => { cleanup(); ws.terminate(); reject(new Error('Relay authentication failed')); };
|
|
57
|
+
const ready = (raw) => {
|
|
58
|
+
try {
|
|
59
|
+
const frame = JSON.parse(raw.toString());
|
|
60
|
+
if (frame.type !== 'ready') {
|
|
61
|
+
fail();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
cleanup();
|
|
65
|
+
accept();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
fail();
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
ws.once('message', ready);
|
|
72
|
+
ws.once('close', fail);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
export function pairingInviteUrl(serviceId, code, value) {
|
|
76
|
+
const url = new URL(value);
|
|
77
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== '/')
|
|
78
|
+
throw new Error('配对地址必须是服务的 HTTP(S) 根地址,不含账号、参数或片段。');
|
|
79
|
+
if (url.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))
|
|
80
|
+
throw new Error('远程配对地址必须使用 HTTPS。');
|
|
81
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(code) || !/^[A-Za-z0-9_.:-]{1,160}$/.test(serviceId))
|
|
82
|
+
throw new Error('Invalid pairing information');
|
|
83
|
+
const origin = url.origin;
|
|
84
|
+
url.hash = 'termdock-invite=' + Buffer.from(JSON.stringify({ v: 1, serviceId, code, entryUrl: origin, serviceUrl: origin })).toString('base64url');
|
|
85
|
+
return url.href;
|
|
86
|
+
}
|
|
87
|
+
export async function runFederationCli(args) {
|
|
88
|
+
try {
|
|
89
|
+
if (args[0] === '--federation-pairing') {
|
|
90
|
+
let explicitUrl;
|
|
91
|
+
let json = false;
|
|
92
|
+
for (let i = 1; i < args.length; i++) {
|
|
93
|
+
if (args[i] === '--json' && !json)
|
|
94
|
+
json = true;
|
|
95
|
+
else if (args[i] === '--url' && explicitUrl === undefined && args[i + 1])
|
|
96
|
+
explicitUrl = args[++i];
|
|
97
|
+
else
|
|
98
|
+
throw new Error('Usage: termdock --federation-pairing [--url https://service:port] [--json]');
|
|
99
|
+
}
|
|
100
|
+
const file = resolve(homedir(), '.termdock/federation/pairing.json');
|
|
101
|
+
if (process.platform !== 'win32' && (statSync(file).mode & 0o077) !== 0)
|
|
102
|
+
throw new Error('配对文件权限异常,请先 chmod 600 配对文件。');
|
|
103
|
+
const data = JSON.parse(readFileSync(file, 'utf8'));
|
|
104
|
+
if (!data || typeof data.serviceId !== 'string' || typeof data.pairingCode !== 'string')
|
|
105
|
+
throw new Error('Invalid pairing file; start the service to initialize pairing');
|
|
106
|
+
if (json) {
|
|
107
|
+
process.stdout.write(`${JSON.stringify({ serviceId: data.serviceId, pairingCode: data.pairingCode, ...(explicitUrl ? { url: explicitUrl } : typeof data.url === 'string' ? { url: data.url } : {}) })}\n`);
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
let serviceUrl = explicitUrl ?? (typeof data.url === 'string' ? data.url : undefined);
|
|
111
|
+
if (!serviceUrl) {
|
|
112
|
+
try {
|
|
113
|
+
const state = JSON.parse(readFileSync(resolve(homedir(), '.termdock/server.json'), 'utf8'));
|
|
114
|
+
serviceUrl = typeof state.lanUrl === 'string' ? state.lanUrl : typeof state.localUrl === 'string' ? state.localUrl : undefined;
|
|
115
|
+
}
|
|
116
|
+
catch { /* Require an explicit usable service URL below. */ }
|
|
117
|
+
}
|
|
118
|
+
if (!serviceUrl)
|
|
119
|
+
throw new Error('请添加 --url https://服务地址:端口,生成可直接打开的配对链接。');
|
|
120
|
+
const invitation = pairingInviteUrl(data.serviceId, data.pairingCode, serviceUrl);
|
|
121
|
+
process.stdout.write(`用新设备打开链接或扫描二维码,即可一次性配对本服务:\n${invitation}\n`);
|
|
122
|
+
process.stdout.write(await QRCode.toString(invitation, { type: 'terminal', small: true }));
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
if (args[0] !== '--federation-relay' || args.length !== 2)
|
|
126
|
+
throw new Error('Usage: termdock --federation-pairing [--url https://service:port] [--json] | termdock --federation-relay <config.json>');
|
|
127
|
+
const configPath = resolve(args[1]);
|
|
128
|
+
if (process.platform !== 'win32' && (statSync(configPath).mode & 0o077) !== 0)
|
|
129
|
+
throw new Error('Relay config contains a token: chmod 600 the config file first');
|
|
130
|
+
const config = parseRelayConfig(JSON.parse(readFileSync(configPath, 'utf8')));
|
|
131
|
+
const loadCa = (file) => file ? readFileSync(resolve(dirname(configPath), file)) : undefined;
|
|
132
|
+
const entryCa = loadCa(config.caPath);
|
|
133
|
+
const targets = config.targets.map(t => ({ ...t, ca: loadCa(t.caPath ?? config.caPath) }));
|
|
134
|
+
const sockets = new Set();
|
|
135
|
+
let stopped = false;
|
|
136
|
+
let client;
|
|
137
|
+
let wake;
|
|
138
|
+
const stop = () => { stopped = true; wake?.(); client?.close(); for (const socket of sockets)
|
|
139
|
+
socket.terminate(); };
|
|
140
|
+
process.once('SIGINT', stop);
|
|
141
|
+
process.once('SIGTERM', stop);
|
|
142
|
+
let delay = 1000;
|
|
143
|
+
try {
|
|
144
|
+
while (!stopped) {
|
|
145
|
+
try {
|
|
146
|
+
const upstream = await connect(secureEndpoint(config.entryUrl, '/api/federation/relay'), entryCa, sockets, config.relayToken, true);
|
|
147
|
+
if (stopped) {
|
|
148
|
+
upstream.terminate();
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
client = new RelayClient(upstream, { targets: new Map(targets.map(t => [t.serviceId, () => connect(secureEndpoint(t.url, '/api/federation/secure'), t.ca, sockets)])) });
|
|
152
|
+
process.stderr.write('加密中继已连接。\n');
|
|
153
|
+
delay = 1000;
|
|
154
|
+
await new Promise(done => { if (upstream.readyState !== WebSocket.OPEN)
|
|
155
|
+
done();
|
|
156
|
+
else
|
|
157
|
+
upstream.once('close', () => done()); });
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
if (!stopped)
|
|
161
|
+
process.stderr.write('中继连接失败或已断开,将重试;请检查授权和证书。\n');
|
|
162
|
+
}
|
|
163
|
+
client?.close();
|
|
164
|
+
client = undefined;
|
|
165
|
+
for (const socket of sockets)
|
|
166
|
+
socket.terminate();
|
|
167
|
+
if (!stopped)
|
|
168
|
+
await new Promise(done => { const timer = setTimeout(() => { wake = undefined; done(); }, delay); wake = () => { clearTimeout(timer); wake = undefined; done(); }; });
|
|
169
|
+
delay = Math.min(delay * 2, 30_000);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
stop();
|
|
174
|
+
process.off('SIGINT', stop);
|
|
175
|
+
process.off('SIGTERM', stop);
|
|
176
|
+
}
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
// Config parsing and network failures must not echo the config or its token.
|
|
181
|
+
const message = error instanceof Error && !('code' in error) && !(error instanceof SyntaxError) ? error.message : '无法读取有效配置或配对信息。';
|
|
182
|
+
process.stderr.write(`${message}\n`);
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
2
|
+
/** Display metadata only; never participates in authorization. */
|
|
3
|
+
export class DeviceNames {
|
|
4
|
+
file;
|
|
5
|
+
constructor(file) {
|
|
6
|
+
this.file = file;
|
|
7
|
+
}
|
|
8
|
+
list() {
|
|
9
|
+
try {
|
|
10
|
+
return Object.fromEntries(Object.entries(JSON.parse(readFileSync(this.file, 'utf8'))).filter(([, name]) => typeof name === 'string'));
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error.code === 'ENOENT')
|
|
14
|
+
return {};
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
set(subjectId, name, onlyIfMissing) {
|
|
19
|
+
if (typeof name !== 'string' || !name.trim() || name.length > 80 || /[\x00-\x1f\x7f]/.test(name))
|
|
20
|
+
throw new Error('INVALID_DEVICE_NAME');
|
|
21
|
+
const names = this.list();
|
|
22
|
+
if (onlyIfMissing && Object.hasOwn(names, subjectId))
|
|
23
|
+
return names[subjectId];
|
|
24
|
+
const next = Object.fromEntries([...Object.entries(names).filter(([id]) => id !== subjectId), [subjectId, name.trim()]]);
|
|
25
|
+
writeFileSync(`${this.file}.tmp`, JSON.stringify(next), { mode: 0o600 });
|
|
26
|
+
renameSync(`${this.file}.tmp`, this.file);
|
|
27
|
+
return name.trim();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createIdentity, secureConnection } from './secureProtocol.js';
|
|
2
|
+
import { socketDuplex } from './socketDuplex.js';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import WebSocket from 'ws';
|
|
6
|
+
function endpoint(config) {
|
|
7
|
+
if (!config || typeof config !== 'object' || Object.keys(config).some(key => !['serviceId', 'url', 'caPath'].includes(key)) || !/^[a-zA-Z0-9_.:-]{1,160}$/.test(config.serviceId) || typeof config.url !== 'string' || (config.caPath !== undefined && typeof config.caPath !== 'string'))
|
|
8
|
+
throw new Error('Invalid direct target configuration');
|
|
9
|
+
const url = new URL(config.url);
|
|
10
|
+
if (!['https:', 'wss:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || !['/', '/api/federation/secure'].includes(url.pathname))
|
|
11
|
+
throw new Error('Direct targets require a verified HTTPS/WSS origin');
|
|
12
|
+
url.protocol = 'wss:';
|
|
13
|
+
url.pathname = '/api/federation/secure';
|
|
14
|
+
return url.toString();
|
|
15
|
+
}
|
|
16
|
+
/** Registers only administrator-configured destinations. TLS validation and redirect
|
|
17
|
+
* refusal apply even though the inner session also has end-to-end encryption. */
|
|
18
|
+
export function attachRegisteredDirectTargets(router, configs, baseDir = process.cwd()) {
|
|
19
|
+
if (!Array.isArray(configs) || configs.length > 64)
|
|
20
|
+
throw new Error('Invalid direct target registry');
|
|
21
|
+
const ids = new Set();
|
|
22
|
+
const targets = configs.map(config => {
|
|
23
|
+
const url = endpoint(config);
|
|
24
|
+
if (ids.has(config.serviceId))
|
|
25
|
+
throw new Error('Duplicate direct target');
|
|
26
|
+
ids.add(config.serviceId);
|
|
27
|
+
return { serviceId: config.serviceId, url, ca: config.caPath ? readFileSync(resolve(baseDir, config.caPath)) : undefined };
|
|
28
|
+
});
|
|
29
|
+
let stopped = false;
|
|
30
|
+
const sockets = new Set();
|
|
31
|
+
const cleanups = [];
|
|
32
|
+
const cleanup = () => { stopped = true; for (const close of cleanups.splice(0))
|
|
33
|
+
close(); for (const ws of sockets)
|
|
34
|
+
ws.terminate(); };
|
|
35
|
+
try {
|
|
36
|
+
for (const target of targets)
|
|
37
|
+
cleanups.push(router.registerDirect(target.serviceId, () => new Promise((accept, reject) => {
|
|
38
|
+
if (stopped) {
|
|
39
|
+
reject(new Error('Direct target stopped'));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const ws = new WebSocket(target.url, { ca: target.ca, rejectUnauthorized: true, followRedirects: false, handshakeTimeout: 10000, maxPayload: 256 * 1024 });
|
|
43
|
+
sockets.add(ws);
|
|
44
|
+
ws.once('close', () => sockets.delete(ws));
|
|
45
|
+
const failed = () => { reject(new Error('Direct target unavailable')); ws.terminate(); };
|
|
46
|
+
ws.once('error', failed);
|
|
47
|
+
ws.once('close', failed);
|
|
48
|
+
ws.once('open', () => { ws.off('error', failed); ws.off('close', failed); ws.on('error', () => ws.terminate()); if (stopped)
|
|
49
|
+
failed();
|
|
50
|
+
else
|
|
51
|
+
accept(ws); });
|
|
52
|
+
})));
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
cleanup();
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
return cleanup;
|
|
59
|
+
}
|
|
60
|
+
/** An administrator enabling a new backup first proves the target's pinned
|
|
61
|
+
* identity; HTTPS validation remains enabled throughout. */
|
|
62
|
+
export async function verifyDirectTarget(config) {
|
|
63
|
+
const ws = new WebSocket(endpoint(config), { rejectUnauthorized: true, followRedirects: false, handshakeTimeout: 5000, maxPayload: 256 * 1024 });
|
|
64
|
+
const duplex = socketDuplex(ws);
|
|
65
|
+
try {
|
|
66
|
+
await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); ws.once('close', () => reject(new Error('Target closed'))); });
|
|
67
|
+
await secureConnection({ identity: await createIdentity(), initiator: true, targetPinnedPeerId: config.serviceId, duplex, signal: AbortSignal.timeout(5000) });
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
ws.close();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readFileSync, openSync, writeFileSync, fsyncSync, closeSync, renameSync, unlinkSync, chmodSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
const TTL = 10 * 60 * 1000;
|
|
5
|
+
const actions = new Set(['session.view', 'session.input', 'session.resize', 'session.terminate']);
|
|
6
|
+
const identity = (v) => typeof v === 'string' && v.length > 0 && v.length <= 512 && !/[\s\x00-\x1f]/.test(v);
|
|
7
|
+
const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
8
|
+
function requireValid(ok) { if (!ok)
|
|
9
|
+
throw new Error('INVALID_INVITATION'); }
|
|
10
|
+
export function validateInvitation(input) {
|
|
11
|
+
requireValid(record(input) && Object.keys(input).every(k => ['scope', 'actions', 'label', 'expiresAt'].includes(k)));
|
|
12
|
+
requireValid(record(input.scope) && Array.isArray(input.actions) && input.actions.length > 0 && new Set(input.actions).size === input.actions.length);
|
|
13
|
+
const scope = input.scope;
|
|
14
|
+
if (scope.kind === 'service') {
|
|
15
|
+
requireValid(Object.keys(scope).length === 1 && ((input.actions.length === 1 && input.actions[0] === 'service:*') || input.actions.every(a => typeof a === 'string' && actions.has(a))));
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
requireValid(scope.kind === 'sessions' && Object.keys(scope).every(k => ['kind', 'sessionIds'].includes(k))
|
|
19
|
+
&& Array.isArray(scope.sessionIds) && scope.sessionIds.length > 0 && scope.sessionIds.length <= 256
|
|
20
|
+
&& scope.sessionIds.every(identity) && new Set(scope.sessionIds).size === scope.sessionIds.length
|
|
21
|
+
&& input.actions.every(a => typeof a === 'string' && actions.has(a)));
|
|
22
|
+
}
|
|
23
|
+
requireValid(input.label === undefined || (typeof input.label === 'string' && input.label.trim().length > 0 && input.label.length <= 80 && !/[\x00-\x1f\x7f]/.test(input.label)));
|
|
24
|
+
requireValid(input.expiresAt === undefined || (Number.isSafeInteger(input.expiresAt) && Number(input.expiresAt) > 0));
|
|
25
|
+
}
|
|
26
|
+
/** Single owning process, synchronous consume-before-grant: a crash may burn an invite,
|
|
27
|
+
* but can never redeem it twice. Raw invitation codes are never persisted. */
|
|
28
|
+
export class InvitationStore {
|
|
29
|
+
options;
|
|
30
|
+
now;
|
|
31
|
+
constructor(options) {
|
|
32
|
+
this.options = options;
|
|
33
|
+
requireValid(identity(options.serviceId));
|
|
34
|
+
this.now = options.now ?? Date.now;
|
|
35
|
+
this.read();
|
|
36
|
+
}
|
|
37
|
+
hash(code) { return createHash('sha256').update(`termdock-invitation-v1:${this.options.serviceId}:`).update(code).digest('hex'); }
|
|
38
|
+
read() {
|
|
39
|
+
let raw;
|
|
40
|
+
try {
|
|
41
|
+
raw = readFileSync(this.options.filePath, 'utf8');
|
|
42
|
+
chmodSync(this.options.filePath, 0o600);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (error.code === 'ENOENT')
|
|
46
|
+
return [];
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
const data = JSON.parse(raw);
|
|
50
|
+
requireValid(record(data) && data.version === 1 && data.serviceId === this.options.serviceId && Array.isArray(data.invitations) && data.invitations.length <= 2048);
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
for (const item of data.invitations) {
|
|
53
|
+
requireValid(record(item) && Object.keys(item).every(k => ['scope', 'actions', 'label', 'expiresAt', 'hash', 'issuerId', 'createdAt', 'invitationExpiresAt', 'consumedAt', 'consumedBy'].includes(k)));
|
|
54
|
+
const { scope, actions, label, expiresAt, hash, issuerId, createdAt, invitationExpiresAt, consumedAt, consumedBy } = item;
|
|
55
|
+
validateInvitation({ scope, actions, ...(label === undefined ? {} : { label }), ...(expiresAt === undefined ? {} : { expiresAt }) });
|
|
56
|
+
requireValid(typeof hash === 'string' && /^[a-f0-9]{64}$/.test(hash) && !seen.has(hash) && identity(issuerId));
|
|
57
|
+
seen.add(hash);
|
|
58
|
+
requireValid(Number.isSafeInteger(createdAt) && Number.isSafeInteger(invitationExpiresAt) && Number(invitationExpiresAt) === Number(createdAt) + TTL);
|
|
59
|
+
requireValid((consumedAt === undefined && consumedBy === undefined) || (Number.isSafeInteger(consumedAt) && identity(consumedBy)));
|
|
60
|
+
}
|
|
61
|
+
return data.invitations;
|
|
62
|
+
}
|
|
63
|
+
save(invitations) {
|
|
64
|
+
mkdirSync(dirname(this.options.filePath), { recursive: true, mode: 0o700 });
|
|
65
|
+
const temporary = `${this.options.filePath}.${randomUUID()}.tmp`;
|
|
66
|
+
let fd;
|
|
67
|
+
try {
|
|
68
|
+
fd = openSync(temporary, 'wx', 0o600);
|
|
69
|
+
writeFileSync(fd, JSON.stringify({ version: 1, serviceId: this.options.serviceId, invitations }, null, 2));
|
|
70
|
+
fsyncSync(fd);
|
|
71
|
+
closeSync(fd);
|
|
72
|
+
fd = undefined;
|
|
73
|
+
renameSync(temporary, this.options.filePath);
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
if (fd !== undefined)
|
|
77
|
+
closeSync(fd);
|
|
78
|
+
try {
|
|
79
|
+
unlinkSync(temporary);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (error.code !== 'ENOENT')
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
create(issuerId, input) {
|
|
88
|
+
requireValid(identity(issuerId));
|
|
89
|
+
validateInvitation(input);
|
|
90
|
+
const now = this.now();
|
|
91
|
+
requireValid(input.expiresAt === undefined || input.expiresAt > now);
|
|
92
|
+
const current = this.read().filter(i => i.consumedAt !== undefined || i.invitationExpiresAt > now);
|
|
93
|
+
if (current.filter(i => i.consumedAt === undefined).length >= 256)
|
|
94
|
+
throw new Error('INVITATION_LIMIT');
|
|
95
|
+
// Keep bounded device-friendly metadata; retain all still-active invitations.
|
|
96
|
+
while (current.length >= 2048) {
|
|
97
|
+
const index = current.findIndex(i => i.consumedAt !== undefined);
|
|
98
|
+
if (index < 0)
|
|
99
|
+
throw new Error('INVITATION_LIMIT');
|
|
100
|
+
current.splice(index, 1);
|
|
101
|
+
}
|
|
102
|
+
const code = randomBytes(32).toString('base64url');
|
|
103
|
+
const invitation = { ...structuredClone(input), ...(input.label ? { label: input.label.trim() } : {}), hash: this.hash(code), issuerId, createdAt: now, invitationExpiresAt: now + TTL };
|
|
104
|
+
current.push(invitation);
|
|
105
|
+
this.save(current);
|
|
106
|
+
return { code, expiresAt: invitation.invitationExpiresAt };
|
|
107
|
+
}
|
|
108
|
+
consume(code, subjectId, issuerAllowed) {
|
|
109
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(code) || !identity(subjectId))
|
|
110
|
+
throw new Error('PAIRING_DENIED');
|
|
111
|
+
const current = this.read(), hash = this.hash(code), now = this.now();
|
|
112
|
+
const invite = current.find(i => i.hash === hash);
|
|
113
|
+
if (!invite || invite.consumedAt !== undefined || invite.invitationExpiresAt <= now || (invite.expiresAt !== undefined && invite.expiresAt <= now) || !issuerAllowed(invite.issuerId))
|
|
114
|
+
throw new Error('PAIRING_DENIED');
|
|
115
|
+
invite.consumedAt = now;
|
|
116
|
+
invite.consumedBy = subjectId;
|
|
117
|
+
this.save(current);
|
|
118
|
+
return { subjectId, scope: structuredClone(invite.scope), actions: [...invite.actions], ...(invite.expiresAt === undefined ? {} : { expiresAt: invite.expiresAt }), canDelegate: false };
|
|
119
|
+
}
|
|
120
|
+
subjectLabels() {
|
|
121
|
+
return Object.fromEntries(this.read().filter(i => i.consumedBy && i.label).map(i => [i.consumedBy, i.label]));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { isAuthEnabled } from '../utils/authProtection.js';
|
|
3
|
+
import { bootstrapCors } from './bootstrapCors.js';
|
|
4
|
+
/** Open mode is per connection and transient; it must never create persisted grants. */
|
|
5
|
+
export function openAccessAllowed(direct, authEnabled, action) {
|
|
6
|
+
return direct && !authEnabled && (!action || !action.startsWith('authorization.') && !action.startsWith('route.'));
|
|
7
|
+
}
|
|
8
|
+
export function createOpenAccessRouter(serviceIdentity) {
|
|
9
|
+
const router = express.Router();
|
|
10
|
+
router.use(bootstrapCors);
|
|
11
|
+
router.get('/parameters', (_req, res) => {
|
|
12
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
13
|
+
if (isAuthEnabled()) {
|
|
14
|
+
res.status(403).json({ error: 'Password authentication required' });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const identity = serviceIdentity();
|
|
18
|
+
if (!identity) {
|
|
19
|
+
res.status(503).json({ error: 'Encrypted access is starting' });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
res.json({ serviceIdentity: identity });
|
|
23
|
+
});
|
|
24
|
+
return router;
|
|
25
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Noise endpoints never use cookies as authority. A browser may legitimately
|
|
2
|
+
* connect to a pinned target from another HTTPS service's PWA; possession of
|
|
3
|
+
* the paired device key is still required before any business operation.
|
|
4
|
+
* Legacy cookie-authenticated endpoints must not use this relaxed policy. */
|
|
5
|
+
export function secureChannelOriginAllowed(origin, sameOriginAllowed) {
|
|
6
|
+
if (origin === undefined)
|
|
7
|
+
return true; // Native CLI/Mac relay.
|
|
8
|
+
try {
|
|
9
|
+
const parsed = new URL(origin);
|
|
10
|
+
if (parsed.origin !== origin)
|
|
11
|
+
return false;
|
|
12
|
+
if (parsed.protocol === 'https:')
|
|
13
|
+
return true;
|
|
14
|
+
return parsed.protocol === 'http:' && sameOriginAllowed();
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** Bounded queues are also used at the untrusted, pre-handshake boundary. */
|
|
2
|
+
export class AsyncQueue {
|
|
3
|
+
limit;
|
|
4
|
+
items = [];
|
|
5
|
+
wake;
|
|
6
|
+
ended = false;
|
|
7
|
+
error;
|
|
8
|
+
constructor(limit = 64) {
|
|
9
|
+
this.limit = limit;
|
|
10
|
+
}
|
|
11
|
+
push(value) {
|
|
12
|
+
if (this.ended)
|
|
13
|
+
throw new Error('Channel closed');
|
|
14
|
+
if (this.items.length >= this.limit) {
|
|
15
|
+
this.end(new Error('Channel queue exceeded'));
|
|
16
|
+
throw this.error;
|
|
17
|
+
}
|
|
18
|
+
this.items.push(value);
|
|
19
|
+
this.wake?.();
|
|
20
|
+
}
|
|
21
|
+
end(error) { this.ended = true; this.error ??= error; this.wake?.(); }
|
|
22
|
+
async *[Symbol.asyncIterator]() {
|
|
23
|
+
while (true) {
|
|
24
|
+
if (this.error)
|
|
25
|
+
throw this.error;
|
|
26
|
+
const item = this.items.shift();
|
|
27
|
+
if (item !== undefined) {
|
|
28
|
+
yield item;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (this.ended)
|
|
32
|
+
return;
|
|
33
|
+
await new Promise(resolve => { this.wake = resolve; });
|
|
34
|
+
this.wake = undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const MAX_PACKET = 2 * 1024 * 1024;
|
|
39
|
+
const encoder = new TextEncoder();
|
|
40
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
41
|
+
/** Application records live entirely inside Noise; transport routing never sees them. */
|
|
42
|
+
export class PacketChannel {
|
|
43
|
+
duplex;
|
|
44
|
+
outgoing = new AsyncQueue(256);
|
|
45
|
+
outgoingBytes = 0;
|
|
46
|
+
done;
|
|
47
|
+
constructor(duplex) {
|
|
48
|
+
this.duplex = duplex;
|
|
49
|
+
const self = this;
|
|
50
|
+
this.done = duplex.sink((async function* () {
|
|
51
|
+
for await (const bytes of self.outgoing) {
|
|
52
|
+
try {
|
|
53
|
+
yield bytes;
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
self.outgoingBytes -= bytes.byteLength;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
})());
|
|
60
|
+
void this.done.catch(error => this.close(error instanceof Error ? error : new Error('Transport failed')));
|
|
61
|
+
}
|
|
62
|
+
send(packet) {
|
|
63
|
+
const body = encoder.encode(JSON.stringify(packet));
|
|
64
|
+
if (body.length > MAX_PACKET)
|
|
65
|
+
throw new Error('Record too large');
|
|
66
|
+
const bytes = new Uint8Array(body.length + 4);
|
|
67
|
+
new DataView(bytes.buffer).setUint32(0, body.length);
|
|
68
|
+
bytes.set(body, 4);
|
|
69
|
+
if (this.outgoingBytes + bytes.byteLength > 4 * 1024 * 1024)
|
|
70
|
+
throw new Error('Record queue byte budget exceeded');
|
|
71
|
+
this.outgoingBytes += bytes.byteLength;
|
|
72
|
+
try {
|
|
73
|
+
this.outgoing.push(bytes);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
this.outgoingBytes -= bytes.byteLength;
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
close(error) { this.outgoing.end(error); this.duplex.close?.(error); }
|
|
81
|
+
async *read() {
|
|
82
|
+
let pending = new Uint8Array(0);
|
|
83
|
+
for await (const chunk of this.duplex.source) {
|
|
84
|
+
if (pending.length + chunk.length > MAX_PACKET * 2 + 8)
|
|
85
|
+
throw new Error('Record buffer exceeded');
|
|
86
|
+
const next = new Uint8Array(pending.length + chunk.length);
|
|
87
|
+
next.set(pending);
|
|
88
|
+
next.set(chunk, pending.length);
|
|
89
|
+
pending = next;
|
|
90
|
+
while (pending.length >= 4) {
|
|
91
|
+
const size = new DataView(pending.buffer, pending.byteOffset, 4).getUint32(0);
|
|
92
|
+
if (size > MAX_PACKET || size === 0)
|
|
93
|
+
throw new Error('Invalid record size');
|
|
94
|
+
if (pending.length < size + 4)
|
|
95
|
+
break;
|
|
96
|
+
const packet = JSON.parse(decoder.decode(pending.subarray(4, size + 4)));
|
|
97
|
+
if (!packet || typeof packet !== 'object' || typeof packet.id !== 'string' || typeof packet.type !== 'string')
|
|
98
|
+
throw new Error('Invalid record');
|
|
99
|
+
yield packet;
|
|
100
|
+
pending = pending.slice(size + 4);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (pending.length)
|
|
104
|
+
throw new Error('Truncated record');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export function toBase64(bytes) {
|
|
108
|
+
let value = '';
|
|
109
|
+
for (let offset = 0; offset < bytes.length; offset += 8192)
|
|
110
|
+
value += String.fromCharCode(...bytes.subarray(offset, offset + 8192));
|
|
111
|
+
return btoa(value);
|
|
112
|
+
}
|
|
113
|
+
export function fromBase64(value) {
|
|
114
|
+
const raw = atob(value);
|
|
115
|
+
return Uint8Array.from(raw, c => c.charCodeAt(0));
|
|
116
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import * as opaque from '@serenity-kit/opaque';
|
|
2
|
+
import { scryptAsync } from '@noble/hashes/scrypt.js';
|
|
3
|
+
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
|
|
4
|
+
// Existing credentials are password-equivalent here. Never send the stored hash
|
|
5
|
+
// over the wire. OPAQUE authenticates both Noise identities before any grant.
|
|
6
|
+
const keyStretching = 'memory-constrained';
|
|
7
|
+
const userIdentifier = 'termdock-password-bootstrap-v1';
|
|
8
|
+
const TTL = 60_000;
|
|
9
|
+
function identifiers(clientIdentity, serverIdentity, origin) {
|
|
10
|
+
if (!clientIdentity || clientIdentity.length > 256 || !serverIdentity || serverIdentity.length > 256 ||
|
|
11
|
+
origin.length > 2048 || new URL(origin).origin !== origin)
|
|
12
|
+
throw new Error('Invalid bootstrap identity');
|
|
13
|
+
return { client: JSON.stringify([userIdentifier, origin, clientIdentity]), server: serverIdentity };
|
|
14
|
+
}
|
|
15
|
+
function bounded(value) {
|
|
16
|
+
if (typeof value !== 'string' || value.length < 1 || value.length > 8192)
|
|
17
|
+
throw new Error('Invalid OPAQUE message');
|
|
18
|
+
}
|
|
19
|
+
export async function startPasswordBootstrap(password, saltHex) {
|
|
20
|
+
if (!/^[0-9a-f]{32}$/.test(saltHex) || !password || password.length > 1024 || new TextEncoder().encode(password).length > 4096)
|
|
21
|
+
throw new Error('Invalid password parameters');
|
|
22
|
+
await opaque.ready;
|
|
23
|
+
const passwordKey = bytesToHex(await scryptAsync(password, hexToBytes(saltHex), { N: 32768, r: 8, p: 1, dkLen: 64, maxmem: 67108864 }));
|
|
24
|
+
return { ...opaque.client.startLogin({ password: passwordKey }), passwordKey };
|
|
25
|
+
}
|
|
26
|
+
export async function finishPasswordBootstrap(state, response, binding) {
|
|
27
|
+
await opaque.ready;
|
|
28
|
+
bounded(response.loginResponse);
|
|
29
|
+
const result = opaque.client.finishLogin({ clientLoginState: state.clientLoginState, password: state.passwordKey,
|
|
30
|
+
loginResponse: response.loginResponse, keyStretching,
|
|
31
|
+
identifiers: identifiers(binding.clientIdentity, response.serverIdentity, binding.origin) });
|
|
32
|
+
if (!result)
|
|
33
|
+
throw new Error('Password authentication failed');
|
|
34
|
+
return { attemptId: response.attemptId, finishLoginRequest: result.finishLoginRequest, serverIdentity: response.serverIdentity };
|
|
35
|
+
}
|
|
36
|
+
export class PasswordBootstrapServer {
|
|
37
|
+
options;
|
|
38
|
+
setup;
|
|
39
|
+
pending = new Map();
|
|
40
|
+
constructor(options) {
|
|
41
|
+
this.options = options;
|
|
42
|
+
}
|
|
43
|
+
now() { return this.options.now?.() ?? Date.now(); }
|
|
44
|
+
credential() {
|
|
45
|
+
const hash = this.options.getPasswordHash();
|
|
46
|
+
if (!hash || !/^scrypt\$[0-9a-f]{32}\$[0-9a-f]{128}$/.test(hash))
|
|
47
|
+
throw new Error('Password authentication unavailable');
|
|
48
|
+
return hash;
|
|
49
|
+
}
|
|
50
|
+
parameters() { return { saltHex: this.credential().split('$')[1] }; }
|
|
51
|
+
async start(input) {
|
|
52
|
+
bounded(input.startLoginRequest);
|
|
53
|
+
const ids = identifiers(input.clientIdentity, this.options.serverIdentity, input.origin);
|
|
54
|
+
for (const [id, entry] of this.pending)
|
|
55
|
+
if (entry.expiresAt <= this.now())
|
|
56
|
+
this.pending.delete(id);
|
|
57
|
+
if (this.pending.size >= 32)
|
|
58
|
+
throw new Error('Too many password login attempts');
|
|
59
|
+
const credential = this.credential();
|
|
60
|
+
await opaque.ready;
|
|
61
|
+
if (this.pending.size >= 32)
|
|
62
|
+
throw new Error('Too many password login attempts');
|
|
63
|
+
this.setup ??= opaque.server.createSetup();
|
|
64
|
+
const password = credential.split('$')[2];
|
|
65
|
+
// Registration is local migration from the existing scrypt credential; no
|
|
66
|
+
// unauthenticated network registration endpoint or password reset is needed.
|
|
67
|
+
const registration = opaque.client.startRegistration({ password });
|
|
68
|
+
const { registrationResponse } = opaque.server.createRegistrationResponse({ ...registration, serverSetup: this.setup, userIdentifier });
|
|
69
|
+
const { registrationRecord } = opaque.client.finishRegistration({ ...registration, registrationResponse, password, identifiers: ids, keyStretching });
|
|
70
|
+
const result = opaque.server.startLogin({ serverSetup: this.setup, registrationRecord, userIdentifier,
|
|
71
|
+
startLoginRequest: input.startLoginRequest, identifiers: ids });
|
|
72
|
+
const attemptId = globalThis.crypto.randomUUID();
|
|
73
|
+
this.pending.set(attemptId, { serverLoginState: result.serverLoginState, clientIdentity: input.clientIdentity, credential, expiresAt: this.now() + TTL });
|
|
74
|
+
return { attemptId, loginResponse: result.loginResponse, serverIdentity: this.options.serverIdentity };
|
|
75
|
+
}
|
|
76
|
+
async finish(input) {
|
|
77
|
+
bounded(input.finishLoginRequest);
|
|
78
|
+
const entry = this.pending.get(input.attemptId);
|
|
79
|
+
this.pending.delete(input.attemptId);
|
|
80
|
+
if (!entry || entry.expiresAt <= this.now() || entry.credential !== this.credential())
|
|
81
|
+
throw new Error('Password login expired');
|
|
82
|
+
await opaque.ready;
|
|
83
|
+
opaque.server.finishLogin({ serverLoginState: entry.serverLoginState, finishLoginRequest: input.finishLoginRequest });
|
|
84
|
+
return { clientIdentity: entry.clientIdentity, serverIdentity: this.options.serverIdentity };
|
|
85
|
+
}
|
|
86
|
+
}
|