diff --git a/web/package.json b/web/package.json index 79b6978..ca5d0b1 100644 --- a/web/package.json +++ b/web/package.json @@ -6,17 +6,20 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "@mantine/core": "^9.3.0", "@mantine/hooks": "^9.3.0", + "@noble/ciphers": "^2.2.0", "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@scure/bip39": "^2.2.0", "@tabler/icons-react": "^3.36.0", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "tweetnacl": "^1.0.3" }, "devDependencies": { "@types/react": "^19.2.0", @@ -26,6 +29,7 @@ "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", "typescript": "~5.6.3", - "vite": "^6.0.3" + "vite": "^6.0.3", + "vitest": "^4.1.8" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 0685fcf..8cecc4d 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@mantine/hooks': specifier: ^9.3.0 version: 9.3.0(react@19.2.7) + '@noble/ciphers': + specifier: ^2.2.0 + version: 2.2.0 '@noble/curves': specifier: ^2.2.0 version: 2.2.0 @@ -32,6 +35,9 @@ importers: react-dom: specifier: ^19.2.0 version: 19.2.7(react@19.2.7) + tweetnacl: + specifier: ^1.0.3 + version: 1.0.3 devDependencies: '@types/react': specifier: ^19.2.0 @@ -57,6 +63,9 @@ importers: vite: specifier: ^6.0.3 version: 6.4.3(sugarss@5.0.1(postcss@8.5.15)) + vitest: + specifier: ^4.1.8 + version: 4.1.8(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15))) packages: @@ -348,6 +357,10 @@ packages: peerDependencies: react: ^19.2.0 + '@noble/ciphers@2.2.0': + resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==} + engines: {node: '>= 20.19.0'} + '@noble/curves@2.2.0': resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==} engines: {node: '>= 20.19.0'} @@ -503,6 +516,9 @@ packages: '@scure/bip39@2.2.0': resolution: {integrity: sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tabler/icons-react@3.44.0': resolution: {integrity: sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==} peerDependencies: @@ -523,6 +539,12 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -540,6 +562,39 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@4.1.8': + resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} + + '@vitest/mocker@4.1.8': + resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + + '@vitest/runner@4.1.8': + resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} + + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} + + '@vitest/spy@4.1.8': + resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} + + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + baseline-browser-mapping@2.10.34: resolution: {integrity: sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==} engines: {node: '>=6.0.0'} @@ -557,6 +612,10 @@ packages: caniuse-lite@1.0.30001797: resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -587,6 +646,9 @@ packages: electron-to-chromium@1.5.368: resolution: {integrity: sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -596,6 +658,13 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -634,6 +703,9 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -646,6 +718,13 @@ packages: resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} engines: {node: '>=18'} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -751,10 +830,19 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + sugarss@5.0.1: resolution: {integrity: sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw==} engines: {node: '>=18.0'} @@ -768,13 +856,27 @@ packages: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + type-fest@5.7.0: resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} engines: {node: '>=20'} @@ -853,6 +955,52 @@ packages: yaml: optional: true + vitest@4.1.8: + resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1109,6 +1257,8 @@ snapshots: dependencies: react: 19.2.7 + '@noble/ciphers@2.2.0': {} + '@noble/curves@2.2.0': dependencies: '@noble/hashes': 2.2.0 @@ -1199,6 +1349,8 @@ snapshots: '@noble/hashes': 2.2.0 '@scure/base': 2.2.0 + '@standard-schema/spec@1.1.0': {} + '@tabler/icons-react@3.44.0(react@19.2.7)': dependencies: '@tabler/icons': 3.44.0 @@ -1227,6 +1379,13 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.9': {} '@types/react-dom@19.2.3(@types/react@19.2.17)': @@ -1249,6 +1408,49 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.1.8': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.8(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15)))': + dependencies: + '@vitest/spy': 4.1.8 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.3(sugarss@5.0.1(postcss@8.5.15)) + + '@vitest/pretty-format@4.1.8': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.8': + dependencies: + '@vitest/utils': 4.1.8 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.8': {} + + '@vitest/utils@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + assertion-error@2.0.1: {} + baseline-browser-mapping@2.10.34: {} browserslist@4.28.2: @@ -1263,6 +1465,8 @@ snapshots: caniuse-lite@1.0.30001797: {} + chai@6.2.2: {} + clsx@2.1.1: {} convert-source-map@2.0.0: {} @@ -1279,6 +1483,8 @@ snapshots: electron-to-chromium@1.5.368: {} + es-module-lexer@2.1.0: {} + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -1310,6 +1516,12 @@ snapshots: escalade@3.2.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -1331,12 +1543,20 @@ snapshots: dependencies: yallist: 3.1.1 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + ms@2.1.3: {} nanoid@3.3.12: {} node-releases@2.0.47: {} + obug@2.1.3: {} + + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -1456,8 +1676,14 @@ snapshots: semver@6.3.1: {} + siginfo@2.0.0: {} + source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@4.1.0: {} + sugarss@5.0.1(postcss@8.5.15): dependencies: postcss: 8.5.15 @@ -1466,13 +1692,21 @@ snapshots: tagged-tag@1.0.0: {} + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + tslib@2.8.1: {} + tweetnacl@1.0.3: {} + type-fest@5.7.0: dependencies: tagged-tag: 1.0.0 @@ -1514,4 +1748,34 @@ snapshots: fsevents: 2.3.3 sugarss: 5.0.1(postcss@8.5.15) + vitest@4.1.8(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15))): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15))) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 6.4.3(sugarss@5.0.1(postcss@8.5.15)) + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + yallist@3.1.1: {} diff --git a/web/src/bus/crypto.ts b/web/src/bus/crypto.ts new file mode 100644 index 0000000..6447ffa --- /dev/null +++ b/web/src/bus/crypto.ts @@ -0,0 +1,131 @@ +// Bus crypto primitives, ported to the browser to match the Go reference +// implementation (functions/cybersecurity in fn-registry) byte-for-byte. The bus +// is end-to-end encrypted; doing the crypto here is what keeps the user's private +// key on the device and out of any server (issue 0001). Parity with Go is enforced +// by the vectors in testdata/vectors.json (see vectors.test.ts). +// +// Primitive map (Go -> here): +// EndpointID -> endpointID : base64url(sha256(signPub)), unpadded +// SignEd25519 -> signEd25519 : Ed25519 detached signature +// verify -> verifyEd25519 +// SealAEAD/Open -> sealAEAD/openAEAD : ChaCha20-Poly1305 (IETF, 12-byte nonce) +// SealKeyBox/Open -> sealKeyBox/openKeyBox : NaCl anonymous sealed box (X25519), +// with the nonce derived as sha512(ephPub||recipientPub)[:24] +// EXACTLY as Go's nacl/box.SealAnonymous (Go uses SHA-512, not +// libsodium's blake2b — matching this is the whole point). + +import { ed25519 } from "@noble/curves/ed25519.js"; +import { chacha20poly1305 } from "@noble/ciphers/chacha.js"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { blake2b } from "@noble/hashes/blake2.js"; +import { concatBytes } from "@noble/hashes/utils.js"; +import nacl from "tweetnacl"; + +// sealedBoxNonce derives the 24-byte nonce for an anonymous sealed box the same way +// Go's nacl/box.SealAnonymous (and libsodium's crypto_box_seal) do: BLAKE2b-192 over +// ephemeralPub || recipientPub. NOT SHA-512 — matching the exact hash is what makes +// a Go-sealed room key openable here. +function sealedBoxNonce(ephPub: Uint8Array, recipientPub: Uint8Array): Uint8Array { + return blake2b(concatBytes(ephPub, recipientPub), { dkLen: 24 }); +} + +// --- byte / encoding helpers (browser-safe; no Buffer) ----------------------- + +export function bytesToHex(b: Uint8Array): string { + let s = ""; + for (const x of b) s += x.toString(16).padStart(2, "0"); + return s; +} + +export function hexToBytes(hex: string): Uint8Array { + if (hex.length % 2 !== 0) throw new Error("hex: odd length"); + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + return out; +} + +// base64 standard (with padding) — matches Go's encoding/json for []byte fields. +export function bytesToBase64(b: Uint8Array): string { + let bin = ""; + for (const x of b) bin += String.fromCharCode(x); + return btoa(bin); +} + +export function base64ToBytes(s: string): Uint8Array { + const bin = atob(s); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +// base64url without padding — matches Go's base64.RawURLEncoding (EndpointID). +export function bytesToBase64URL(b: Uint8Array): string { + return bytesToBase64(b).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +// --- identity / signing ------------------------------------------------------ + +// endpointID is the stable, transport-agnostic peer id: base64url(sha256(signPub)). +export function endpointID(signPub: Uint8Array): string { + return bytesToBase64URL(sha256(signPub)); +} + +// signEd25519 signs msg with an Ed25519 private key. It accepts the bus/Go 64-byte +// private key (seed || pub) OR a bare 32-byte seed; @noble signs from the 32-byte +// seed, so we slice the seed out of the 64-byte form. +export function signEd25519(priv: Uint8Array, msg: Uint8Array): Uint8Array { + const seed = priv.length === 64 ? priv.subarray(0, 32) : priv; + return ed25519.sign(msg, seed); +} + +export function verifyEd25519(sig: Uint8Array, msg: Uint8Array, pub: Uint8Array): boolean { + return ed25519.verify(sig, msg, pub); +} + +// --- AEAD (room message content) --------------------------------------------- + +// sealAEAD encrypts plaintext with ChaCha20-Poly1305 (IETF, 12-byte nonce). The +// caller supplies the nonce so the operation is testable; in the bus a fresh random +// nonce is generated per message and stored alongside the ciphertext. +export function sealAEAD(key: Uint8Array, nonce: Uint8Array, plaintext: Uint8Array, aad?: Uint8Array): Uint8Array { + return chacha20poly1305(key, nonce, aad).encrypt(plaintext); +} + +export function openAEAD(key: Uint8Array, nonce: Uint8Array, ciphertext: Uint8Array, aad?: Uint8Array): Uint8Array { + return chacha20poly1305(key, nonce, aad).decrypt(ciphertext); +} + +// randomNonce returns a fresh 12-byte AEAD nonce (ChaCha20-Poly1305 IETF size). +export function randomNonce(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(12)); +} + +// --- anonymous sealed box (room key distribution) ---------------------------- + +// sealKeyBox seals secret to a recipient's X25519 public key as an anonymous NaCl +// sealed box, matching Go's nacl/box.SealAnonymous: an ephemeral keypair is created, +// the nonce is sha512(ephPub || recipientPub)[:24], and the output is +// ephPub(32) || box(secret). The recipient opens it with openKeyBox; the sender is +// anonymous (no long-term sender key is revealed). +export function sealKeyBox(recipientKexPub: Uint8Array, secret: Uint8Array): Uint8Array { + const eph = nacl.box.keyPair(); + const nonce = sealedBoxNonce(eph.publicKey, recipientKexPub); + const boxed = nacl.box(secret, nonce, recipientKexPub, eph.secretKey); + return concatBytes(eph.publicKey, boxed); +} + +// openKeyBox opens an anonymous sealed box produced by sealKeyBox (or Go's +// SealKeyBox). It re-derives the same sha512-based nonce from the embedded ephemeral +// public key and the recipient's own public key, then opens the box with the +// recipient's private key. Returns null if authentication fails. +export function openKeyBox( + recipientKexPub: Uint8Array, + recipientKexPriv: Uint8Array, + sealed: Uint8Array, +): Uint8Array | null { + if (sealed.length < 32) return null; + const ephPub = sealed.subarray(0, 32); + const boxed = sealed.subarray(32); + const nonce = sealedBoxNonce(ephPub, recipientKexPub); + return nacl.box.open(boxed, nonce, ephPub, recipientKexPriv); +} diff --git a/web/src/bus/frame.ts b/web/src/bus/frame.ts new file mode 100644 index 0000000..bc76b2a --- /dev/null +++ b/web/src/bus/frame.ts @@ -0,0 +1,140 @@ +// The wire format of the unibus message bus, ported from Go pkg/frame. A Frame is +// the unit transported over NATS: a cleartext envelope plus an optional AEAD +// ciphertext payload, signed end-to-end with Ed25519. +// +// The signature covers the canonical JSON of the frame with the signature field +// cleared, so the marshaler here must reproduce Go's encoding/json BYTE FOR BYTE or +// signatures verified by Go peers would fail. That means: struct field order, the +// `omitempty` rules, base64-standard encoding of []byte fields, and Go's default +// HTML escaping of <, >, & and the U+2028/U+2029 separators inside strings. Parity +// is pinned by testdata/vectors.json (vectors.test.ts). + +import { + bytesToBase64, + base64ToBytes, + signEd25519, + verifyEd25519, + endpointID, +} from "./crypto.js"; + +export enum FrameType { + PUB = 0, + INVITE = 1, + JOIN = 2, + LEAVE = 3, + KICK = 4, + ACK = 5, + REACT = 6, +} + +export interface BlobRef { + hash: string; // sha256 hex of the blob ciphertext + nonce: Uint8Array; // AEAD nonce used to encrypt the blob + size: number; // ciphertext size in bytes +} + +export interface Frame { + type: FrameType; + subject: string; + sender: string; // endpoint id = endpointID(signPub) + msgID: string; // ULID + epoch: number; // epoch of the room key used to encrypt + threadID?: string; // root message id of the thread (optional) + replyTo?: string; // message id this frame replies to / reacts to (optional) + nonce?: Uint8Array; // AEAD nonce (encrypted rooms only) + payload?: Uint8Array; // AEAD ciphertext (or cleartext if the room is not encrypted) + blob?: BlobRef; + sig?: Uint8Array; // Ed25519 signature over signingBytes() +} + +// Go's encoding/json HTML-escapes these code points inside strings by default. We +// replay the exact same set so our canonical bytes match Go's. The two separators +// (U+2028 line separator, U+2029 paragraph separator) are built via fromCharCode so +// this source file holds no invisible characters while the RegExp still matches the +// real code points at runtime. +const GO_ESCAPES: ReadonlyArray<[RegExp, string]> = [ + [//g, "\\u003e"], + [/&/g, "\\u0026"], + [new RegExp(String.fromCharCode(0x2028), "g"), "\\u2028"], + [new RegExp(String.fromCharCode(0x2029), "g"), "\\u2029"], +]; + +// goJSONStringify serializes obj the way Go's encoding/json does: compact (no +// spaces), insertion-ordered keys, and the default HTML escaping above. Apply only +// to objects built key-by-key in field order, so the output matches Go's struct +// marshaling exactly. +function goJSONStringify(obj: Record): string { + let s = JSON.stringify(obj); + for (const [re, rep] of GO_ESCAPES) s = s.replace(re, rep); + return s; +} + +// frameObject builds the plain object with keys inserted in Go struct-declaration +// order, applying each field's omitempty rule. includeSig controls whether the +// signature field is emitted: false yields the canonical signing-bytes object. +function frameObject(f: Frame, includeSig: boolean): Record { + const o: Record = {}; + // Always-present fields (no omitempty in Go). + o.t = f.type; + o.s = f.subject; + o.from = f.sender; + o.id = f.msgID; + o.e = f.epoch; + // omitempty fields, in declaration order. + if (f.threadID) o.thr = f.threadID; + if (f.replyTo) o.re = f.replyTo; + if (f.nonce && f.nonce.length) o.n = bytesToBase64(f.nonce); + if (f.payload && f.payload.length) o.p = bytesToBase64(f.payload); + if (f.blob) o.b = { h: f.blob.hash, n: bytesToBase64(f.blob.nonce), sz: f.blob.size }; + if (includeSig && f.sig && f.sig.length) o.sig = bytesToBase64(f.sig); + return o; +} + +// marshal returns the wire bytes of the frame (UTF-8 of the canonical JSON). +export function marshal(f: Frame): Uint8Array { + return new TextEncoder().encode(goJSONStringify(frameObject(f, true))); +} + +// signingBytes returns the canonical bytes that are signed and verified: the frame +// JSON with the signature field cleared. +export function signingBytes(f: Frame): Uint8Array { + return new TextEncoder().encode(goJSONStringify(frameObject(f, false))); +} + +// unmarshal parses wire bytes back into a Frame, decoding the base64 []byte fields. +export function unmarshal(b: Uint8Array): Frame { + const o = JSON.parse(new TextDecoder().decode(b)); + const f: Frame = { + type: o.t ?? 0, + subject: o.s ?? "", + sender: o.from ?? "", + msgID: o.id ?? "", + epoch: o.e ?? 0, + }; + if (o.thr) f.threadID = o.thr; + if (o.re) f.replyTo = o.re; + if (o.n) f.nonce = base64ToBytes(o.n); + if (o.p) f.payload = base64ToBytes(o.p); + if (o.b) f.blob = { hash: o.b.h, nonce: base64ToBytes(o.b.n), size: o.b.sz }; + if (o.sig) f.sig = base64ToBytes(o.sig); + return f; +} + +// signFrame fills f.sig with an Ed25519 signature over signingBytes(f). signPriv is +// the 64-byte (seed||pub) or 32-byte seed private key. +export function signFrame(f: Frame, signPriv: Uint8Array): Frame { + f.sig = signEd25519(signPriv, signingBytes(f)); + return f; +} + +// verifyFrame checks f.sig against signPub over signingBytes(f). +export function verifyFrame(f: Frame, signPub: Uint8Array): boolean { + if (!f.sig) return false; + return verifyEd25519(f.sig, signingBytes(f), signPub); +} + +// senderEndpoint derives the canonical sender endpoint id from a signing public key. +export function senderEndpoint(signPub: Uint8Array): string { + return endpointID(signPub); +} diff --git a/web/src/bus/vectors.test.ts b/web/src/bus/vectors.test.ts new file mode 100644 index 0000000..2f505da --- /dev/null +++ b/web/src/bus/vectors.test.ts @@ -0,0 +1,127 @@ +// Cross-language parity tests: the TypeScript bus SDK must reproduce the Go +// reference implementation byte-for-byte. The golden vectors in testdata/vectors.json +// are generated by unibus `cmd/busvectors`. Any divergence here means a browser +// client and a Go/Kotlin peer would not interoperate (issue 0001, Phase 1). + +import { describe, it, expect } from "vitest"; +import vectors from "./testdata/vectors.json"; +import { + hexToBytes, + bytesToHex, + base64ToBytes, + endpointID, + signEd25519, + verifyEd25519, + sealAEAD, + openAEAD, + openKeyBox, + sealKeyBox, +} from "./crypto.js"; +import { Frame, FrameType, marshal, signingBytes, signFrame, verifyFrame } from "./frame.js"; + +describe("endpoint id", () => { + it("matches Go EndpointID = base64url(sha256(signPub))", () => { + const v = vectors.endpoint_id; + expect(endpointID(hexToBytes(v.sign_pub_hex))).toBe(v.endpoint_id); + }); +}); + +describe("Ed25519 signing", () => { + it("produces the same deterministic signature as Go", () => { + const v = vectors.sign; + const sig = signEd25519(hexToBytes(v.sign_priv_hex), hexToBytes(v.message_hex)); + expect(bytesToHex(sig)).toBe(v.sig_hex); + }); + + it("verifies the Go-produced signature", () => { + const v = vectors.sign; + const ok = verifyEd25519(hexToBytes(v.sig_hex), hexToBytes(v.message_hex), hexToBytes(v.sign_pub_hex)); + expect(ok).toBe(true); + }); +}); + +describe("ChaCha20-Poly1305 AEAD", () => { + it("opens the Go-sealed ciphertext", () => { + const v = vectors.aead; + const pt = openAEAD( + hexToBytes(v.key_hex), + hexToBytes(v.nonce_hex), + hexToBytes(v.ciphertext_hex), + hexToBytes(v.aad_hex), + ); + expect(bytesToHex(pt)).toBe(v.plaintext_hex); + }); + + it("seals to the same ciphertext as Go with a fixed nonce", () => { + const v = vectors.aead; + const ct = sealAEAD( + hexToBytes(v.key_hex), + hexToBytes(v.nonce_hex), + hexToBytes(v.plaintext_hex), + hexToBytes(v.aad_hex), + ); + expect(bytesToHex(ct)).toBe(v.ciphertext_hex); + }); +}); + +describe("anonymous sealed box (room key distribution)", () => { + it("opens the Go-sealed room key", () => { + const v = vectors.keybox; + const secret = openKeyBox( + hexToBytes(v.recipient_kex_pub_hex), + hexToBytes(v.recipient_kex_priv_hex), + hexToBytes(v.sealed_hex), + ); + expect(secret).not.toBeNull(); + expect(bytesToHex(secret!)).toBe(v.secret_hex); + }); + + it("round-trips a TS-sealed box (seal then open)", () => { + const v = vectors.keybox; + const pub = hexToBytes(v.recipient_kex_pub_hex); + const priv = hexToBytes(v.recipient_kex_priv_hex); + const secret = hexToBytes(v.secret_hex); + const sealed = sealKeyBox(pub, secret); + const opened = openKeyBox(pub, priv, sealed); + expect(opened).not.toBeNull(); + expect(bytesToHex(opened!)).toBe(v.secret_hex); + }); +}); + +describe("Frame wire format", () => { + function vectorFrame(): Frame { + const v = vectors.frame; + return { + type: v.type as FrameType, + subject: v.subject, + sender: v.sender, + msgID: v.msg_id, + epoch: v.epoch, + nonce: hexToBytes(v.nonce_hex), + payload: hexToBytes(v.payload_hex), + }; + } + + it("produces the same canonical signing bytes as Go", () => { + const got = signingBytes(vectorFrame()); + const want = base64ToBytes(vectors.frame.signing_bytes_b64); + expect(bytesToHex(got)).toBe(bytesToHex(want)); + }); + + it("signs the frame to the same Ed25519 signature as Go", () => { + const f = signFrame(vectorFrame(), hexToBytes(vectors.sign.sign_priv_hex)); + expect(bytesToHex(f.sig!)).toBe(vectors.frame.sig_hex); + }); + + it("marshals the signed frame to the same wire bytes as Go", () => { + const f = signFrame(vectorFrame(), hexToBytes(vectors.sign.sign_priv_hex)); + const got = marshal(f); + const want = base64ToBytes(vectors.frame.wire_b64); + expect(bytesToHex(got)).toBe(bytesToHex(want)); + }); + + it("verifies the marshaled frame signature against the signer pubkey", () => { + const f = signFrame(vectorFrame(), hexToBytes(vectors.sign.sign_priv_hex)); + expect(verifyFrame(f, hexToBytes(vectors.sign.sign_pub_hex))).toBe(true); + }); +});