From 4326276505f9cca008c6f5de8203c5cac969d532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=9E=AB?= Date: Tue, 17 Mar 2026 14:56:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20CookieBridge=20M1=20?= =?UTF-8?q?=E2=80=94=20core=20protocol=20&=20relay=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Protocol spec: encrypted envelope format, device identity (Ed25519 + X25519), LWW conflict resolution with Lamport clocks - E2E encryption: XChaCha20-Poly1305 via sodium-native, X25519 key exchange - WebSocket relay server: stateless message forwarding, device auth via challenge-response, offline message queuing, ping/pong keepalive - Device pairing: time-limited pairing codes, key exchange broker via HTTP - Sync protocol: envelope builder/opener, conflict-resolving cookie store - 31 tests passing (crypto, pairing, conflict resolution, full integration) Co-Authored-By: Paperclip --- package-lock.json | 2105 +++++++++++++++++++++++++++++++++++++ package.json | 33 + src/cli.ts | 21 + src/crypto/encryption.ts | 68 ++ src/crypto/index.ts | 11 + src/crypto/keys.ts | 55 + src/crypto/signing.ts | 39 + src/index.ts | 39 + src/pairing/index.ts | 2 + src/pairing/pairing.ts | 70 ++ src/protocol/spec.ts | 143 +++ src/relay/auth.ts | 23 + src/relay/connections.ts | 93 ++ src/relay/index.ts | 3 + src/relay/server.ts | 328 ++++++ src/sync/conflict.ts | 97 ++ src/sync/envelope.ts | 69 ++ src/sync/index.ts | 2 + tests/conflict.test.ts | 138 +++ tests/crypto.test.ts | 124 +++ tests/integration.test.ts | 301 ++++++ tests/pairing.test.ts | 57 + tsconfig.json | 18 + vitest.config.ts | 8 + 24 files changed, 3847 insertions(+) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/cli.ts create mode 100644 src/crypto/encryption.ts create mode 100644 src/crypto/index.ts create mode 100644 src/crypto/keys.ts create mode 100644 src/crypto/signing.ts create mode 100644 src/index.ts create mode 100644 src/pairing/index.ts create mode 100644 src/pairing/pairing.ts create mode 100644 src/protocol/spec.ts create mode 100644 src/relay/auth.ts create mode 100644 src/relay/connections.ts create mode 100644 src/relay/index.ts create mode 100644 src/relay/server.ts create mode 100644 src/sync/conflict.ts create mode 100644 src/sync/envelope.ts create mode 100644 src/sync/index.ts create mode 100644 tests/conflict.test.ts create mode 100644 tests/crypto.test.ts create mode 100644 tests/integration.test.ts create mode 100644 tests/pairing.test.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b68e97e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2105 @@ +{ + "name": "cookiebridge", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cookiebridge", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "sodium-native": "^5.1.0", + "typescript": "^5.9.3", + "uuid": "^13.0.0", + "ws": "^8.19.0" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "@types/sodium-native": "^2.3.9", + "@types/uuid": "^10.0.0", + "@types/ws": "^8.18.1", + "tsx": "^4.21.0", + "vitest": "^4.1.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", + "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-project/runtime": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", + "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", + "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", + "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", + "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", + "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", + "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", + "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", + "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", + "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", + "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", + "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/sodium-native": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@types/sodium-native/-/sodium-native-2.3.9.tgz", + "integrity": "sha512-jZIg5ltGH1okmnH3FrLQsgwjcjOVozMSHwSiEm1/LpMekhOMHbQqp21P4H24mizh1BjwI6Q8qmphmD/HJuAqWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.0", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-addon-resolve": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.10.0.tgz", + "integrity": "sha512-sSd0jieRJlDaODOzj0oe0RjFVC1QI0ZIjGIdPkbrTXsdVVtENg14c+lHHAhHwmWCZ2nQlMhy8jA3Y5LYPc/isA==", + "license": "Apache-2.0", + "dependencies": { + "bare-module-resolve": "^1.10.0", + "bare-semver": "^1.0.0" + }, + "peerDependencies": { + "bare-url": "*" + }, + "peerDependenciesMeta": { + "bare-url": { + "optional": true + } + } + }, + "node_modules/bare-ansi-escapes": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/bare-ansi-escapes/-/bare-ansi-escapes-2.2.3.tgz", + "integrity": "sha512-02ES4/E2RbrtZSnHJ9LntBhYkLA6lPpSEeP8iqS3MccBIVhVBlEmruF1I7HZqx5Q8aiTeYfQVeqmrU9YO2yYoQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-stream": "^2.6.5" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-assert": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/bare-assert/-/bare-assert-1.2.0.tgz", + "integrity": "sha512-c6uvgvTJBspTDxtVnPgrBKmLgcpW3Fp72NVKDLg6oT4QjQbhGtvrkHMhGYMK1sh4vjBHOBmuUalyt9hSzV37fQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-inspect": "^3.1.2" + } + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-inspect": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/bare-inspect/-/bare-inspect-3.1.4.tgz", + "integrity": "sha512-jfW5KRA84o3REpI6Vr4nbvMn+hqVAw8GU1mMdRwUsY5yJovQamxYeKGVKGqdzs+8ZbG4jRzGUXP/3Ji/DnqfPg==", + "license": "Apache-2.0", + "dependencies": { + "bare-ansi-escapes": "^2.1.0", + "bare-type": "^1.0.0" + }, + "engines": { + "bare": ">=1.18.0" + } + }, + "node_modules/bare-module-resolve": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.1.tgz", + "integrity": "sha512-hbmAPyFpEq8FoZMd5sFO3u6MC5feluWoGE8YKlA8fCrl6mNtx68Wjg4DTiDJcqRJaovTvOYKfYngoBUnbaT7eg==", + "license": "Apache-2.0", + "dependencies": { + "bare-semver": "^1.0.0" + }, + "peerDependencies": { + "bare-url": "*" + }, + "peerDependenciesMeta": { + "bare-url": { + "optional": true + } + } + }, + "node_modules/bare-semver": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.0.2.tgz", + "integrity": "sha512-ESVaN2nzWhcI5tf3Zzcq9aqCZ676VWzqw07eEZ0qxAcEOAFYBa0pWq8sK34OQeHLY3JsfKXZS9mDyzyxGjeLzA==", + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.1.tgz", + "integrity": "sha512-bSeR8RfvbRwDpD7HWZvn8M3uYNDrk7m9DQjYOFkENZlXW8Ju/MPaqUPQq5LqJ3kyjEm07siTaAQ7wBKCU59oHg==", + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.21.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bare-type/-/bare-type-1.1.0.tgz", + "integrity": "sha512-LdtnnEEYldOc87Dr4GpsKnStStZk3zfgoEMXy8yvEZkXrcCv9RtYDrUYWFsBQHtaB0s1EUWmcvS6XmEZYIj3Bw==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.2.0" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/require-addon": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.2.0.tgz", + "integrity": "sha512-VNPDZlYgIYQwWp9jMTzljx+k0ZtatKlcvOhktZ/anNPI3dQ9NXk7cq2U4iJ1wd9IrytRnYhyEocFWbkdPb+MYA==", + "license": "Apache-2.0", + "dependencies": { + "bare-addon-resolve": "^1.3.0" + }, + "engines": { + "bare": ">=1.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", + "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.115.0", + "@rolldown/pluginutils": "1.0.0-rc.9" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.9", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", + "@rolldown/binding-darwin-x64": "1.0.0-rc.9", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sodium-native": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz", + "integrity": "sha512-3RxgyWyJlhTsABPnJVpCI5CoTDANZTqqFrEPqr+kjfnRaBihpVtMUE3yTF40ukdoB1APXeoBNKF3MzZAIHg39g==", + "license": "MIT", + "dependencies": { + "bare-assert": "^1.2.0", + "require-addon": "^1.1.0", + "which-runtime": "^1.2.1" + }, + "engines": { + "bare": ">=1.16.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/vite": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", + "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/runtime": "0.115.0", + "lightningcss": "^1.32.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.9", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.0.0-alpha.31", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "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.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.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/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which-runtime": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/which-runtime/-/which-runtime-1.3.2.tgz", + "integrity": "sha512-5kwCfWml7+b2NO7KrLMhYihjRx0teKkd3yGp1Xk5Vaf2JGdSh+rgVhEALAD9c/59dP+YwJHXoEO7e8QPy7gOkw==", + "license": "Apache-2.0" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6d0d403 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "cookiebridge", + "version": "0.1.0", + "description": "Cross-device cookie synchronization with E2E encryption", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "start": "tsx src/cli.ts", + "dev": "tsx --watch src/cli.ts", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "keywords": ["cookies", "sync", "encryption", "browser-extension"], + "author": "Rc707Agency", + "license": "MIT", + "type": "commonjs", + "dependencies": { + "sodium-native": "^5.1.0", + "typescript": "^5.9.3", + "uuid": "^13.0.0", + "ws": "^8.19.0" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "@types/sodium-native": "^2.3.9", + "@types/uuid": "^10.0.0", + "@types/ws": "^8.18.1", + "tsx": "^4.21.0", + "vitest": "^4.1.0" + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..78e1c41 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,21 @@ +import { RelayServer } from "./relay/index.js"; + +const port = parseInt(process.env.PORT ?? "8080", 10); +const host = process.env.HOST ?? "0.0.0.0"; + +const server = new RelayServer({ port, host }); + +server.start().then(() => { + console.log(`CookieBridge relay server listening on ${host}:${port}`); +}); + +process.on("SIGINT", async () => { + console.log("\nShutting down..."); + await server.stop(); + process.exit(0); +}); + +process.on("SIGTERM", async () => { + await server.stop(); + process.exit(0); +}); diff --git a/src/crypto/encryption.ts b/src/crypto/encryption.ts new file mode 100644 index 0000000..90f29fc --- /dev/null +++ b/src/crypto/encryption.ts @@ -0,0 +1,68 @@ +import sodium from "sodium-native"; + +/** + * Derive a shared secret from our X25519 secret key and peer's X25519 public key. + * Uses crypto_scalarmult (raw X25519 ECDH) then hashes with crypto_generichash + * to produce a 32-byte symmetric key. + */ +export function deriveSharedKey( + ourEncSec: Buffer, + peerEncPub: Buffer, +): Buffer { + const raw = Buffer.alloc(sodium.crypto_scalarmult_BYTES); + sodium.crypto_scalarmult(raw, ourEncSec, peerEncPub); + + // Hash the raw shared secret to get a uniform key + const sharedKey = Buffer.alloc(32); + sodium.crypto_generichash(sharedKey, raw); + return sharedKey; +} + +/** + * Encrypt plaintext using XChaCha20-Poly1305 with the shared key. + * Returns { nonce, ciphertext } where both are Buffers. + */ +export function encrypt( + plaintext: Buffer, + sharedKey: Buffer, +): { nonce: Buffer; ciphertext: Buffer } { + const nonce = Buffer.alloc(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES); + sodium.randombytes_buf(nonce); + + const ciphertext = Buffer.alloc( + plaintext.length + sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES, + ); + sodium.crypto_aead_xchacha20poly1305_ietf_encrypt( + ciphertext, + plaintext, + null, // no additional data + null, // unused nsec + nonce, + sharedKey, + ); + + return { nonce, ciphertext }; +} + +/** + * Decrypt ciphertext using XChaCha20-Poly1305 with the shared key. + * Returns the plaintext Buffer, or throws on authentication failure. + */ +export function decrypt( + ciphertext: Buffer, + nonce: Buffer, + sharedKey: Buffer, +): Buffer { + const plaintext = Buffer.alloc( + ciphertext.length - sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES, + ); + sodium.crypto_aead_xchacha20poly1305_ietf_decrypt( + plaintext, + null, // unused nsec + ciphertext, + null, // no additional data + nonce, + sharedKey, + ); + return plaintext; +} diff --git a/src/crypto/index.ts b/src/crypto/index.ts new file mode 100644 index 0000000..e1fc88b --- /dev/null +++ b/src/crypto/index.ts @@ -0,0 +1,11 @@ +export { + generateKeyPair, + deviceIdFromKeys, + serializeKeyPair, + deserializeKeyPair, +} from "./keys.js"; +export type { DeviceKeyPair, SerializedKeyPair } from "./keys.js"; + +export { deriveSharedKey, encrypt, decrypt } from "./encryption.js"; + +export { sign, verify, buildSignablePayload } from "./signing.js"; diff --git a/src/crypto/keys.ts b/src/crypto/keys.ts new file mode 100644 index 0000000..f7dd702 --- /dev/null +++ b/src/crypto/keys.ts @@ -0,0 +1,55 @@ +import sodium from "sodium-native"; + +export interface DeviceKeyPair { + // Ed25519 for signing/identity + signPub: Buffer; + signSec: Buffer; + // X25519 for key exchange + encPub: Buffer; + encSec: Buffer; +} + +export interface SerializedKeyPair { + signPub: string; // hex + signSec: string; // hex + encPub: string; // hex + encSec: string; // hex +} + +/** Generate a new device keypair (Ed25519 + X25519). */ +export function generateKeyPair(): DeviceKeyPair { + const signPub = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES); + const signSec = Buffer.alloc(sodium.crypto_sign_SECRETKEYBYTES); + sodium.crypto_sign_keypair(signPub, signSec); + + const encPub = Buffer.alloc(sodium.crypto_box_PUBLICKEYBYTES); + const encSec = Buffer.alloc(sodium.crypto_box_SECRETKEYBYTES); + sodium.crypto_box_keypair(encPub, encSec); + + return { signPub, signSec, encPub, encSec }; +} + +/** Derive deviceId from signing public key (hex string). */ +export function deviceIdFromKeys(keys: DeviceKeyPair): string { + return keys.signPub.toString("hex"); +} + +/** Serialize keypair for storage. */ +export function serializeKeyPair(keys: DeviceKeyPair): SerializedKeyPair { + return { + signPub: keys.signPub.toString("hex"), + signSec: keys.signSec.toString("hex"), + encPub: keys.encPub.toString("hex"), + encSec: keys.encSec.toString("hex"), + }; +} + +/** Deserialize keypair from storage. */ +export function deserializeKeyPair(data: SerializedKeyPair): DeviceKeyPair { + return { + signPub: Buffer.from(data.signPub, "hex"), + signSec: Buffer.from(data.signSec, "hex"), + encPub: Buffer.from(data.encPub, "hex"), + encSec: Buffer.from(data.encSec, "hex"), + }; +} diff --git a/src/crypto/signing.ts b/src/crypto/signing.ts new file mode 100644 index 0000000..6a3cbb2 --- /dev/null +++ b/src/crypto/signing.ts @@ -0,0 +1,39 @@ +import sodium from "sodium-native"; + +/** Sign a message with Ed25519 secret key. Returns the signature (64 bytes). */ +export function sign(message: Buffer, signSec: Buffer): Buffer { + const sig = Buffer.alloc(sodium.crypto_sign_BYTES); + sodium.crypto_sign_detached(sig, message, signSec); + return sig; +} + +/** Verify an Ed25519 signature. Returns true if valid. */ +export function verify( + message: Buffer, + sig: Buffer, + signPub: Buffer, +): boolean { + return sodium.crypto_sign_verify_detached(sig, message, signPub); +} + +/** + * Build the signable payload from envelope fields. + * Concatenates: type + from + to + nonce + payload + timestamp + */ +export function buildSignablePayload(fields: { + type: string; + from: string; + to: string; + nonce: string; + payload: string; + timestamp: string; +}): Buffer { + return Buffer.from( + fields.type + + fields.from + + fields.to + + fields.nonce + + fields.payload + + fields.timestamp, + ); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..16955c0 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,39 @@ +export { RelayServer } from "./relay/index.js"; +export type { RelayServerConfig } from "./relay/index.js"; + +export { + generateKeyPair, + deviceIdFromKeys, + serializeKeyPair, + deserializeKeyPair, + deriveSharedKey, + encrypt, + decrypt, + sign, + verify, + buildSignablePayload, +} from "./crypto/index.js"; +export type { DeviceKeyPair, SerializedKeyPair } from "./crypto/index.js"; + +export { PairingStore, generatePairingCode } from "./pairing/index.js"; +export type { PairingSession } from "./pairing/index.js"; + +export { buildEnvelope, openEnvelope, CookieStore } from "./sync/index.js"; + +export { + PROTOCOL_VERSION, + MESSAGE_TYPES, + MAX_OFFLINE_QUEUE, + PAIRING_CODE_LENGTH, + PAIRING_TTL_MS, +} from "./protocol/spec.js"; +export type { + Envelope, + MessageType, + CookieEntry, + CookieSyncPayload, + PairingRequest, + PairingAccept, + PairingResult, + DeviceInfo, +} from "./protocol/spec.js"; diff --git a/src/pairing/index.ts b/src/pairing/index.ts new file mode 100644 index 0000000..c856a84 --- /dev/null +++ b/src/pairing/index.ts @@ -0,0 +1,2 @@ +export { PairingStore, generatePairingCode } from "./pairing.js"; +export type { PairingSession } from "./pairing.js"; diff --git a/src/pairing/pairing.ts b/src/pairing/pairing.ts new file mode 100644 index 0000000..4a1f225 --- /dev/null +++ b/src/pairing/pairing.ts @@ -0,0 +1,70 @@ +import sodium from "sodium-native"; +import { PAIRING_CODE_LENGTH, PAIRING_TTL_MS } from "../protocol/spec.js"; + +export interface PairingSession { + pairingCode: string; + deviceId: string; + x25519PubKey: string; + createdAt: number; + expiresAt: number; +} + +/** Generate a random numeric pairing code. */ +export function generatePairingCode(): string { + const buf = Buffer.alloc(4); + sodium.randombytes_buf(buf); + const num = buf.readUInt32BE(0) % Math.pow(10, PAIRING_CODE_LENGTH); + return num.toString().padStart(PAIRING_CODE_LENGTH, "0"); +} + +/** + * In-memory pairing session store. + * In production this could be backed by Redis with TTL. + */ +export class PairingStore { + private sessions = new Map(); + + create(deviceId: string, x25519PubKey: string): PairingSession { + this.pruneExpired(); + const code = generatePairingCode(); + const now = Date.now(); + const session: PairingSession = { + pairingCode: code, + deviceId, + x25519PubKey, + createdAt: now, + expiresAt: now + PAIRING_TTL_MS, + }; + this.sessions.set(code, session); + return session; + } + + /** Look up a session by code. Returns null if expired or not found. */ + find(pairingCode: string): PairingSession | null { + const session = this.sessions.get(pairingCode); + if (!session) return null; + if (Date.now() > session.expiresAt) { + this.sessions.delete(pairingCode); + return null; + } + return session; + } + + /** Remove a session after successful pairing. */ + consume(pairingCode: string): PairingSession | null { + const session = this.find(pairingCode); + if (session) { + this.sessions.delete(pairingCode); + } + return session; + } + + private pruneExpired(): void { + const now = Date.now(); + for (const [code, session] of this.sessions) { + if (now > session.expiresAt) { + this.sessions.delete(code); + } + } + } +} diff --git a/src/protocol/spec.ts b/src/protocol/spec.ts new file mode 100644 index 0000000..53c6aba --- /dev/null +++ b/src/protocol/spec.ts @@ -0,0 +1,143 @@ +/** + * CookieBridge Protocol Specification + * + * Architecture: + * Device A <--E2E encrypted--> Relay Server <--E2E encrypted--> Device B + * The relay never sees plaintext cookies. It forwards opaque encrypted blobs. + * + * Device Identity: + * Each device generates an X25519 keypair for key exchange and an Ed25519 + * keypair for signing. The device is identified by its Ed25519 public key + * (the "deviceId"). + * + * Pairing Flow: + * 1. Device A generates a pairing code (random 6-digit + short-lived secret). + * 2. Device A registers a pairing session with the relay (POST /pair). + * 3. Device B enters the code (or scans QR) and POSTs to /pair/accept. + * 4. Relay brokers the X25519 public key exchange. + * 5. Both devices derive a shared secret via X25519 ECDH. + * 6. Pairing session is deleted from relay. Devices store each other's keys locally. + * + * Sync Protocol: + * Messages are sent over WebSocket as JSON envelopes wrapping encrypted payloads. + * + * Envelope: { type, from, to, nonce, payload, timestamp, sig } + * - type: message type (cookie_sync, cookie_delete, ack, ping, pong) + * - from: sender deviceId (Ed25519 pubkey hex) + * - to: recipient deviceId + * - nonce: random 24-byte nonce (hex) + * - payload: XChaCha20-Poly1305 ciphertext (base64) + * - timestamp: ISO-8601 + * - sig: Ed25519 signature over (type + from + to + nonce + payload + timestamp) + * + * The relay authenticates devices by verifying `sig` against `from`. + * The relay routes by `to`. If the recipient is offline, the message is + * queued (up to a configurable limit) and delivered on reconnect. + * + * Conflict Resolution: + * Last-writer-wins (LWW) per (domain, cookie-name) pair. + * Each cookie sync payload includes a logical timestamp (Lamport clock). + * Recipient applies the update only if the incoming timestamp > local timestamp + * for that key. Ties broken by deviceId lexicographic order. + * + * Encrypted Payload (after decryption): + * { + * action: "set" | "delete", + * cookies: [{ domain, name, value, path, secure, httpOnly, sameSite, expiresAt }], + * lamportTs: number + * } + */ + +// --- Message Types --- + +export const MESSAGE_TYPES = { + COOKIE_SYNC: "cookie_sync", + COOKIE_DELETE: "cookie_delete", + ACK: "ack", + PING: "ping", + PONG: "pong", + ERROR: "error", +} as const; + +export type MessageType = (typeof MESSAGE_TYPES)[keyof typeof MESSAGE_TYPES]; + +// --- Wire Envelope --- + +export interface Envelope { + type: MessageType; + from: string; // deviceId (Ed25519 pubkey hex) + to: string; // recipient deviceId + nonce: string; // 24-byte hex + payload: string; // encrypted ciphertext, base64 + timestamp: string; // ISO-8601 + sig: string; // Ed25519 signature hex +} + +// --- Decrypted Payloads --- + +export interface CookieEntry { + domain: string; + name: string; + value: string; + path: string; + secure: boolean; + httpOnly: boolean; + sameSite: "strict" | "lax" | "none"; + expiresAt: string | null; // ISO-8601 or null for session cookies +} + +export interface CookieSyncPayload { + action: "set" | "delete"; + cookies: CookieEntry[]; + lamportTs: number; +} + +// --- Pairing --- + +export interface PairingRequest { + deviceId: string; // Ed25519 pubkey hex + x25519PubKey: string; // X25519 pubkey hex + pairingCode: string; // 6-digit code +} + +export interface PairingAccept { + deviceId: string; + x25519PubKey: string; + pairingCode: string; +} + +export interface PairingResult { + peerDeviceId: string; + peerX25519PubKey: string; +} + +// --- Device Registration --- + +export interface DeviceInfo { + deviceId: string; // Ed25519 pubkey hex + name: string; + platform: string; + createdAt: string; +} + +// --- Relay Auth --- + +export interface RelayAuthChallenge { + challenge: string; // random bytes hex +} + +export interface RelayAuthResponse { + deviceId: string; + challenge: string; + sig: string; // Ed25519 signature of challenge +} + +// --- Protocol Constants --- + +export const PROTOCOL_VERSION = "1.0.0"; +export const MAX_OFFLINE_QUEUE = 1000; +export const PAIRING_CODE_LENGTH = 6; +export const PAIRING_TTL_MS = 5 * 60 * 1000; // 5 minutes +export const NONCE_BYTES = 24; +export const PING_INTERVAL_MS = 30_000; +export const PONG_TIMEOUT_MS = 10_000; diff --git a/src/relay/auth.ts b/src/relay/auth.ts new file mode 100644 index 0000000..1f99aa2 --- /dev/null +++ b/src/relay/auth.ts @@ -0,0 +1,23 @@ +import sodium from "sodium-native"; +import { verify } from "../crypto/signing.js"; + +/** + * Generate a random authentication challenge. + */ +export function generateChallenge(): Buffer { + const challenge = Buffer.alloc(32); + sodium.randombytes_buf(challenge); + return challenge; +} + +/** + * Verify a device's authentication response. + * The device must sign the challenge with its Ed25519 key. + */ +export function verifyAuthResponse( + challenge: Buffer, + sig: Buffer, + deviceSignPub: Buffer, +): boolean { + return verify(challenge, sig, deviceSignPub); +} diff --git a/src/relay/connections.ts b/src/relay/connections.ts new file mode 100644 index 0000000..ba9b06b --- /dev/null +++ b/src/relay/connections.ts @@ -0,0 +1,93 @@ +import type { WebSocket } from "ws"; +import type { Envelope } from "../protocol/spec.js"; +import { MAX_OFFLINE_QUEUE } from "../protocol/spec.js"; + +export interface ConnectedDevice { + deviceId: string; + ws: WebSocket; + authenticatedAt: number; +} + +/** + * Manages WebSocket connections and offline message queues. + */ +export class ConnectionManager { + private connections = new Map(); + private offlineQueues = new Map(); + + /** Register an authenticated device connection. */ + register(deviceId: string, ws: WebSocket): void { + // Close any existing connection for this device + const existing = this.connections.get(deviceId); + if (existing && existing.ws !== ws) { + existing.ws.close(4001, "Replaced by new connection"); + } + + this.connections.set(deviceId, { + deviceId, + ws, + authenticatedAt: Date.now(), + }); + + // Flush any queued messages + this.flushQueue(deviceId); + } + + /** Remove a device connection. */ + remove(deviceId: string, ws: WebSocket): void { + const conn = this.connections.get(deviceId); + // Only remove if it's the same WebSocket (not a newer replacement) + if (conn && conn.ws === ws) { + this.connections.delete(deviceId); + } + } + + /** Send an envelope to a device. Queues if offline. */ + send(to: string, envelope: Envelope): boolean { + const conn = this.connections.get(to); + if (conn && conn.ws.readyState === 1 /* OPEN */) { + conn.ws.send(JSON.stringify(envelope)); + return true; + } + + // Queue for offline delivery + this.enqueue(to, envelope); + return false; + } + + /** Check if a device is currently connected. */ + isOnline(deviceId: string): boolean { + const conn = this.connections.get(deviceId); + return conn !== undefined && conn.ws.readyState === 1; + } + + /** Get count of connected devices. */ + get connectedCount(): number { + return this.connections.size; + } + + private enqueue(deviceId: string, envelope: Envelope): void { + let queue = this.offlineQueues.get(deviceId); + if (!queue) { + queue = []; + this.offlineQueues.set(deviceId, queue); + } + if (queue.length >= MAX_OFFLINE_QUEUE) { + queue.shift(); // Drop oldest + } + queue.push(envelope); + } + + private flushQueue(deviceId: string): void { + const queue = this.offlineQueues.get(deviceId); + if (!queue || queue.length === 0) return; + + const conn = this.connections.get(deviceId); + if (!conn || conn.ws.readyState !== 1) return; + + for (const envelope of queue) { + conn.ws.send(JSON.stringify(envelope)); + } + this.offlineQueues.delete(deviceId); + } +} diff --git a/src/relay/index.ts b/src/relay/index.ts new file mode 100644 index 0000000..85c9330 --- /dev/null +++ b/src/relay/index.ts @@ -0,0 +1,3 @@ +export { RelayServer } from "./server.js"; +export type { RelayServerConfig } from "./server.js"; +export { ConnectionManager } from "./connections.js"; diff --git a/src/relay/server.ts b/src/relay/server.ts new file mode 100644 index 0000000..84a9265 --- /dev/null +++ b/src/relay/server.ts @@ -0,0 +1,328 @@ +import { WebSocketServer, WebSocket } from "ws"; +import http from "node:http"; +import { ConnectionManager } from "./connections.js"; +import { generateChallenge, verifyAuthResponse } from "./auth.js"; +import { verify, buildSignablePayload } from "../crypto/signing.js"; +import { PairingStore } from "../pairing/pairing.js"; +import { + type Envelope, + type MessageType, + MESSAGE_TYPES, + PING_INTERVAL_MS, + PONG_TIMEOUT_MS, +} from "../protocol/spec.js"; + +export interface RelayServerConfig { + port: number; + host?: string; +} + +interface PendingAuth { + challenge: Buffer; + createdAt: number; +} + +/** + * CookieBridge Relay Server. + * + * HTTP endpoints: + * POST /pair — initiate a pairing session + * POST /pair/accept — accept a pairing session + * GET /health — health check + * + * WebSocket: + * /ws — authenticated device connection for message relay + */ +export class RelayServer { + private httpServer: http.Server; + private wss: WebSocketServer; + private connections: ConnectionManager; + private pairingStore: PairingStore; + private pendingAuths = new Map(); + private authenticatedDevices = new Map(); // ws -> deviceId + private pingIntervals = new Map>(); + + constructor(private config: RelayServerConfig) { + this.connections = new ConnectionManager(); + this.pairingStore = new PairingStore(); + + this.httpServer = http.createServer(this.handleHttp.bind(this)); + this.wss = new WebSocketServer({ server: this.httpServer }); + this.wss.on("connection", this.handleConnection.bind(this)); + } + + start(): Promise { + return new Promise((resolve) => { + this.httpServer.listen( + this.config.port, + this.config.host ?? "0.0.0.0", + () => resolve(), + ); + }); + } + + stop(): Promise { + return new Promise((resolve) => { + for (const interval of this.pingIntervals.values()) { + clearInterval(interval); + } + this.wss.close(() => { + this.httpServer.close(() => resolve()); + }); + }); + } + + get port(): number { + const addr = this.httpServer.address(); + if (addr && typeof addr === "object") return addr.port; + return this.config.port; + } + + // --- HTTP --- + + private handleHttp(req: http.IncomingMessage, res: http.ServerResponse): void { + if (req.method === "GET" && req.url === "/health") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "ok", connections: this.connections.connectedCount })); + return; + } + + if (req.method === "POST" && req.url === "/pair") { + this.handlePairCreate(req, res); + return; + } + + if (req.method === "POST" && req.url === "/pair/accept") { + this.handlePairAccept(req, res); + return; + } + + res.writeHead(404); + res.end("Not found"); + } + + private handlePairCreate(req: http.IncomingMessage, res: http.ServerResponse): void { + this.readBody(req, (body) => { + try { + const { deviceId, x25519PubKey } = JSON.parse(body); + if (!deviceId || !x25519PubKey) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Missing deviceId or x25519PubKey" })); + return; + } + const session = this.pairingStore.create(deviceId, x25519PubKey); + res.writeHead(201, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ pairingCode: session.pairingCode, expiresAt: session.expiresAt })); + } catch { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid JSON" })); + } + }); + } + + private handlePairAccept(req: http.IncomingMessage, res: http.ServerResponse): void { + this.readBody(req, (body) => { + try { + const { deviceId, x25519PubKey, pairingCode } = JSON.parse(body); + if (!deviceId || !x25519PubKey || !pairingCode) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Missing required fields" })); + return; + } + const session = this.pairingStore.consume(pairingCode); + if (!session) { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid or expired pairing code" })); + return; + } + // Return both peers' info + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + initiator: { + deviceId: session.deviceId, + x25519PubKey: session.x25519PubKey, + }, + acceptor: { + deviceId, + x25519PubKey, + }, + }), + ); + } catch { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid JSON" })); + } + }); + } + + private readBody(req: http.IncomingMessage, cb: (body: string) => void): void { + let data = ""; + req.on("data", (chunk: Buffer) => { + data += chunk.toString(); + if (data.length > 64 * 1024) { + req.destroy(); + } + }); + req.on("end", () => cb(data)); + } + + // --- WebSocket --- + + private handleConnection(ws: WebSocket): void { + // Send auth challenge + const challenge = generateChallenge(); + this.pendingAuths.set(ws, { challenge, createdAt: Date.now() }); + ws.send(JSON.stringify({ type: "auth_challenge", challenge: challenge.toString("hex") })); + + ws.on("message", (data: Buffer) => { + this.handleMessage(ws, data); + }); + + ws.on("close", () => { + this.handleDisconnect(ws); + }); + + ws.on("error", () => { + this.handleDisconnect(ws); + }); + + // Auth timeout — disconnect if not authenticated within 10s + setTimeout(() => { + if (this.pendingAuths.has(ws)) { + ws.close(4000, "Auth timeout"); + this.pendingAuths.delete(ws); + } + }, 10_000); + } + + private handleMessage(ws: WebSocket, data: Buffer): void { + let msg: Record; + try { + msg = JSON.parse(data.toString()); + } catch { + ws.send(JSON.stringify({ type: "error", error: "Invalid JSON" })); + return; + } + + // Handle auth response + if (msg.type === "auth_response") { + this.handleAuthResponse(ws, msg); + return; + } + + // All other messages require authentication + const deviceId = this.authenticatedDevices.get(ws); + if (!deviceId) { + ws.send(JSON.stringify({ type: "error", error: "Not authenticated" })); + return; + } + + // Handle ping/pong + if (msg.type === MESSAGE_TYPES.PING) { + ws.send(JSON.stringify({ type: MESSAGE_TYPES.PONG })); + return; + } + + // Handle relay messages + if ( + msg.type === MESSAGE_TYPES.COOKIE_SYNC || + msg.type === MESSAGE_TYPES.COOKIE_DELETE || + msg.type === MESSAGE_TYPES.ACK + ) { + this.handleRelayMessage(ws, deviceId, msg as unknown as Envelope); + return; + } + + ws.send(JSON.stringify({ type: "error", error: "Unknown message type" })); + } + + private handleAuthResponse(ws: WebSocket, msg: Record): void { + const pending = this.pendingAuths.get(ws); + if (!pending) { + ws.send(JSON.stringify({ type: "error", error: "No pending auth challenge" })); + return; + } + + const { deviceId, sig } = msg as { deviceId: string; sig: string }; + if (!deviceId || !sig) { + ws.close(4002, "Invalid auth response"); + return; + } + + const sigBuf = Buffer.from(sig, "hex"); + const pubBuf = Buffer.from(deviceId, "hex"); + + if (!verifyAuthResponse(pending.challenge, sigBuf, pubBuf)) { + ws.close(4003, "Auth failed"); + this.pendingAuths.delete(ws); + return; + } + + // Authenticated + this.pendingAuths.delete(ws); + this.authenticatedDevices.set(ws, deviceId); + this.connections.register(deviceId, ws); + + ws.send(JSON.stringify({ type: "auth_ok", deviceId })); + + // Start ping interval + const interval = setInterval(() => { + if (ws.readyState === 1) { + ws.send(JSON.stringify({ type: MESSAGE_TYPES.PING })); + } + }, PING_INTERVAL_MS); + this.pingIntervals.set(ws, interval); + } + + private handleRelayMessage(ws: WebSocket, fromDeviceId: string, envelope: Envelope): void { + // Verify the 'from' matches the authenticated device + if (envelope.from !== fromDeviceId) { + ws.send(JSON.stringify({ type: "error", error: "Sender mismatch" })); + return; + } + + // Verify signature + const signable = buildSignablePayload({ + type: envelope.type, + from: envelope.from, + to: envelope.to, + nonce: envelope.nonce, + payload: envelope.payload, + timestamp: envelope.timestamp, + }); + const sigBuf = Buffer.from(envelope.sig, "hex"); + const pubBuf = Buffer.from(fromDeviceId, "hex"); + + if (!verify(signable, sigBuf, pubBuf)) { + ws.send(JSON.stringify({ type: "error", error: "Invalid signature" })); + return; + } + + // Route to recipient + const delivered = this.connections.send(envelope.to, envelope); + + // Acknowledge to sender + ws.send( + JSON.stringify({ + type: MESSAGE_TYPES.ACK, + ref: envelope.nonce, + delivered, + }), + ); + } + + private handleDisconnect(ws: WebSocket): void { + const deviceId = this.authenticatedDevices.get(ws); + if (deviceId) { + this.connections.remove(deviceId, ws); + this.authenticatedDevices.delete(ws); + } + this.pendingAuths.delete(ws); + const interval = this.pingIntervals.get(ws); + if (interval) { + clearInterval(interval); + this.pingIntervals.delete(ws); + } + } +} diff --git a/src/sync/conflict.ts b/src/sync/conflict.ts new file mode 100644 index 0000000..6be7ed8 --- /dev/null +++ b/src/sync/conflict.ts @@ -0,0 +1,97 @@ +import type { CookieEntry, CookieSyncPayload } from "../protocol/spec.js"; + +type CookieKey = string; // "domain|name|path" + +interface TrackedCookie { + entry: CookieEntry; + lamportTs: number; + sourceDeviceId: string; +} + +function cookieKey(entry: CookieEntry): CookieKey { + return `${entry.domain}|${entry.name}|${entry.path}`; +} + +/** + * Last-Writer-Wins cookie store with Lamport clock conflict resolution. + * Ties broken by deviceId lexicographic order. + */ +export class CookieStore { + private cookies = new Map(); + private lamportClock = 0; + + /** Get current Lamport timestamp. */ + get currentTs(): number { + return this.lamportClock; + } + + /** Tick the Lamport clock and return the new value. */ + tick(): number { + return ++this.lamportClock; + } + + /** + * Apply an incoming sync payload. Returns the list of cookies that were + * actually applied (i.e., won the conflict resolution). + */ + applyRemote( + payload: CookieSyncPayload, + sourceDeviceId: string, + ): CookieEntry[] { + // Update our Lamport clock + this.lamportClock = Math.max(this.lamportClock, payload.lamportTs) + 1; + + const applied: CookieEntry[] = []; + + for (const entry of payload.cookies) { + const key = cookieKey(entry); + const existing = this.cookies.get(key); + + if (payload.action === "delete") { + if (this.shouldApply(existing, payload.lamportTs, sourceDeviceId)) { + this.cookies.delete(key); + applied.push(entry); + } + continue; + } + + // action === "set" + if (this.shouldApply(existing, payload.lamportTs, sourceDeviceId)) { + this.cookies.set(key, { + entry, + lamportTs: payload.lamportTs, + sourceDeviceId, + }); + applied.push(entry); + } + } + + return applied; + } + + /** Record a local cookie change. Returns the Lamport timestamp for the sync payload. */ + setLocal(entry: CookieEntry, localDeviceId: string): number { + const ts = this.tick(); + const key = cookieKey(entry); + this.cookies.set(key, { entry, lamportTs: ts, sourceDeviceId: localDeviceId }); + return ts; + } + + /** Get a snapshot of all tracked cookies. */ + getAll(): CookieEntry[] { + return Array.from(this.cookies.values()).map((t) => t.entry); + } + + private shouldApply( + existing: TrackedCookie | undefined, + incomingTs: number, + incomingDeviceId: string, + ): boolean { + if (!existing) return true; + if (incomingTs > existing.lamportTs) return true; + if (incomingTs === existing.lamportTs) { + return incomingDeviceId > existing.sourceDeviceId; + } + return false; + } +} diff --git a/src/sync/envelope.ts b/src/sync/envelope.ts new file mode 100644 index 0000000..de1a710 --- /dev/null +++ b/src/sync/envelope.ts @@ -0,0 +1,69 @@ +import { + type Envelope, + type MessageType, + type CookieSyncPayload, +} from "../protocol/spec.js"; +import { + type DeviceKeyPair, + deviceIdFromKeys, + deriveSharedKey, + encrypt, + decrypt, +} from "../crypto/index.js"; +import { sign, buildSignablePayload } from "../crypto/signing.js"; + +/** + * Build a signed, encrypted envelope ready to send over the relay. + */ +export function buildEnvelope( + type: MessageType, + payload: CookieSyncPayload, + senderKeys: DeviceKeyPair, + peerEncPub: Buffer, + peerDeviceId: string, +): Envelope { + const fromId = deviceIdFromKeys(senderKeys); + const sharedKey = deriveSharedKey(senderKeys.encSec, peerEncPub); + + const plaintext = Buffer.from(JSON.stringify(payload)); + const { nonce, ciphertext } = encrypt(plaintext, sharedKey); + + const timestamp = new Date().toISOString(); + const nonceHex = nonce.toString("hex"); + const payloadB64 = ciphertext.toString("base64"); + + const signable = buildSignablePayload({ + type, + from: fromId, + to: peerDeviceId, + nonce: nonceHex, + payload: payloadB64, + timestamp, + }); + const sig = sign(signable, senderKeys.signSec); + + return { + type, + from: fromId, + to: peerDeviceId, + nonce: nonceHex, + payload: payloadB64, + timestamp, + sig: sig.toString("hex"), + }; +} + +/** + * Decrypt and parse an incoming envelope's payload. + */ +export function openEnvelope( + envelope: Envelope, + receiverKeys: DeviceKeyPair, + peerEncPub: Buffer, +): CookieSyncPayload { + const sharedKey = deriveSharedKey(receiverKeys.encSec, peerEncPub); + const nonce = Buffer.from(envelope.nonce, "hex"); + const ciphertext = Buffer.from(envelope.payload, "base64"); + const plaintext = decrypt(ciphertext, nonce, sharedKey); + return JSON.parse(plaintext.toString()) as CookieSyncPayload; +} diff --git a/src/sync/index.ts b/src/sync/index.ts new file mode 100644 index 0000000..f2ecfec --- /dev/null +++ b/src/sync/index.ts @@ -0,0 +1,2 @@ +export { buildEnvelope, openEnvelope } from "./envelope.js"; +export { CookieStore } from "./conflict.js"; diff --git a/tests/conflict.test.ts b/tests/conflict.test.ts new file mode 100644 index 0000000..20adcb3 --- /dev/null +++ b/tests/conflict.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from "vitest"; +import { CookieStore } from "../src/sync/conflict.js"; +import type { CookieEntry, CookieSyncPayload } from "../src/protocol/spec.js"; + +function makeCookie(overrides: Partial = {}): CookieEntry { + return { + domain: "example.com", + name: "session", + value: "abc123", + path: "/", + secure: true, + httpOnly: true, + sameSite: "lax", + expiresAt: null, + ...overrides, + }; +} + +describe("CookieStore — conflict resolution", () => { + it("applies first write", () => { + const store = new CookieStore(); + const payload: CookieSyncPayload = { + action: "set", + cookies: [makeCookie({ value: "v1" })], + lamportTs: 1, + }; + const applied = store.applyRemote(payload, "device-a"); + expect(applied).toHaveLength(1); + expect(store.getAll()).toHaveLength(1); + expect(store.getAll()[0].value).toBe("v1"); + }); + + it("last-writer-wins: higher timestamp wins", () => { + const store = new CookieStore(); + store.applyRemote( + { action: "set", cookies: [makeCookie({ value: "old" })], lamportTs: 1 }, + "device-a", + ); + store.applyRemote( + { action: "set", cookies: [makeCookie({ value: "new" })], lamportTs: 5 }, + "device-b", + ); + expect(store.getAll()[0].value).toBe("new"); + }); + + it("rejects stale update", () => { + const store = new CookieStore(); + store.applyRemote( + { action: "set", cookies: [makeCookie({ value: "newer" })], lamportTs: 10 }, + "device-a", + ); + const applied = store.applyRemote( + { action: "set", cookies: [makeCookie({ value: "older" })], lamportTs: 3 }, + "device-b", + ); + expect(applied).toHaveLength(0); + expect(store.getAll()[0].value).toBe("newer"); + }); + + it("breaks ties by deviceId (lexicographic)", () => { + const store = new CookieStore(); + store.applyRemote( + { action: "set", cookies: [makeCookie({ value: "from-a" })], lamportTs: 5 }, + "aaa", + ); + // Same timestamp, higher deviceId wins + const applied = store.applyRemote( + { action: "set", cookies: [makeCookie({ value: "from-b" })], lamportTs: 5 }, + "bbb", + ); + expect(applied).toHaveLength(1); + expect(store.getAll()[0].value).toBe("from-b"); + }); + + it("tie-break: lower deviceId loses", () => { + const store = new CookieStore(); + store.applyRemote( + { action: "set", cookies: [makeCookie({ value: "from-z" })], lamportTs: 5 }, + "zzz", + ); + const applied = store.applyRemote( + { action: "set", cookies: [makeCookie({ value: "from-a" })], lamportTs: 5 }, + "aaa", + ); + expect(applied).toHaveLength(0); + expect(store.getAll()[0].value).toBe("from-z"); + }); + + it("handles delete action", () => { + const store = new CookieStore(); + store.applyRemote( + { action: "set", cookies: [makeCookie()], lamportTs: 1 }, + "device-a", + ); + expect(store.getAll()).toHaveLength(1); + + store.applyRemote( + { action: "delete", cookies: [makeCookie()], lamportTs: 5 }, + "device-a", + ); + expect(store.getAll()).toHaveLength(0); + }); + + it("tracks local changes with Lamport clock", () => { + const store = new CookieStore(); + const cookie = makeCookie({ value: "local" }); + const ts = store.setLocal(cookie, "my-device"); + expect(ts).toBe(1); + expect(store.getAll()).toHaveLength(1); + expect(store.currentTs).toBe(1); + + const ts2 = store.setLocal(makeCookie({ name: "other", value: "x" }), "my-device"); + expect(ts2).toBe(2); + }); + + it("advances Lamport clock on remote apply", () => { + const store = new CookieStore(); + store.applyRemote( + { action: "set", cookies: [makeCookie()], lamportTs: 100 }, + "device-x", + ); + // Clock should be max(0, 100) + 1 = 101 + expect(store.currentTs).toBe(101); + }); + + it("tracks different cookies by domain|name|path", () => { + const store = new CookieStore(); + store.applyRemote( + { action: "set", cookies: [makeCookie({ domain: "a.com", name: "s1" })], lamportTs: 1 }, + "d1", + ); + store.applyRemote( + { action: "set", cookies: [makeCookie({ domain: "b.com", name: "s1" })], lamportTs: 1 }, + "d1", + ); + expect(store.getAll()).toHaveLength(2); + }); +}); diff --git a/tests/crypto.test.ts b/tests/crypto.test.ts new file mode 100644 index 0000000..e3f60ef --- /dev/null +++ b/tests/crypto.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest"; +import { + generateKeyPair, + deviceIdFromKeys, + serializeKeyPair, + deserializeKeyPair, + deriveSharedKey, + encrypt, + decrypt, + sign, + verify, + buildSignablePayload, +} from "../src/crypto/index.js"; + +describe("Key generation", () => { + it("generates unique keypairs", () => { + const kp1 = generateKeyPair(); + const kp2 = generateKeyPair(); + expect(kp1.signPub).not.toEqual(kp2.signPub); + expect(kp1.encPub).not.toEqual(kp2.encPub); + }); + + it("derives deviceId from signing public key", () => { + const kp = generateKeyPair(); + const id = deviceIdFromKeys(kp); + expect(id).toBe(kp.signPub.toString("hex")); + expect(id).toHaveLength(64); // 32 bytes hex + }); + + it("serializes and deserializes keypair", () => { + const kp = generateKeyPair(); + const serialized = serializeKeyPair(kp); + const restored = deserializeKeyPair(serialized); + expect(restored.signPub).toEqual(kp.signPub); + expect(restored.signSec).toEqual(kp.signSec); + expect(restored.encPub).toEqual(kp.encPub); + expect(restored.encSec).toEqual(kp.encSec); + }); +}); + +describe("Encryption", () => { + it("encrypts and decrypts with shared key", () => { + const alice = generateKeyPair(); + const bob = generateKeyPair(); + + const aliceShared = deriveSharedKey(alice.encSec, bob.encPub); + const bobShared = deriveSharedKey(bob.encSec, alice.encPub); + + // Both sides derive the same shared key + expect(aliceShared).toEqual(bobShared); + + const plaintext = Buffer.from("hello cookies"); + const { nonce, ciphertext } = encrypt(plaintext, aliceShared); + + const decrypted = decrypt(ciphertext, nonce, bobShared); + expect(decrypted.toString()).toBe("hello cookies"); + }); + + it("fails to decrypt with wrong key", () => { + const alice = generateKeyPair(); + const bob = generateKeyPair(); + const eve = generateKeyPair(); + + const sharedKey = deriveSharedKey(alice.encSec, bob.encPub); + const wrongKey = deriveSharedKey(eve.encSec, bob.encPub); + + const plaintext = Buffer.from("secret"); + const { nonce, ciphertext } = encrypt(plaintext, sharedKey); + + expect(() => decrypt(ciphertext, nonce, wrongKey)).toThrow(); + }); + + it("produces different ciphertexts for same plaintext (random nonce)", () => { + const alice = generateKeyPair(); + const bob = generateKeyPair(); + const shared = deriveSharedKey(alice.encSec, bob.encPub); + + const plaintext = Buffer.from("same message"); + const r1 = encrypt(plaintext, shared); + const r2 = encrypt(plaintext, shared); + + expect(r1.nonce).not.toEqual(r2.nonce); + expect(r1.ciphertext).not.toEqual(r2.ciphertext); + }); +}); + +describe("Signing", () => { + it("signs and verifies", () => { + const kp = generateKeyPair(); + const msg = Buffer.from("test message"); + const sig = sign(msg, kp.signSec); + expect(verify(msg, sig, kp.signPub)).toBe(true); + }); + + it("rejects tampered message", () => { + const kp = generateKeyPair(); + const msg = Buffer.from("original"); + const sig = sign(msg, kp.signSec); + const tampered = Buffer.from("tampered"); + expect(verify(tampered, sig, kp.signPub)).toBe(false); + }); + + it("rejects wrong signer", () => { + const alice = generateKeyPair(); + const bob = generateKeyPair(); + const msg = Buffer.from("from alice"); + const sig = sign(msg, alice.signSec); + expect(verify(msg, sig, bob.signPub)).toBe(false); + }); + + it("builds deterministic signable payload", () => { + const fields = { + type: "cookie_sync", + from: "aaa", + to: "bbb", + nonce: "ccc", + payload: "ddd", + timestamp: "2024-01-01T00:00:00.000Z", + }; + const p1 = buildSignablePayload(fields); + const p2 = buildSignablePayload(fields); + expect(p1).toEqual(p2); + }); +}); diff --git a/tests/integration.test.ts b/tests/integration.test.ts new file mode 100644 index 0000000..b2d1cc1 --- /dev/null +++ b/tests/integration.test.ts @@ -0,0 +1,301 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import WebSocket from "ws"; +import { + RelayServer, + generateKeyPair, + deviceIdFromKeys, + deriveSharedKey, + sign, + MESSAGE_TYPES, +} from "../src/index.js"; +import { buildEnvelope, openEnvelope } from "../src/sync/envelope.js"; +import type { CookieSyncPayload, Envelope } from "../src/protocol/spec.js"; + +// Helper: connect and authenticate a device +function connectDevice( + port: number, + keys: ReturnType, +): Promise<{ ws: WebSocket; deviceId: string }> { + return new Promise((resolve, reject) => { + const deviceId = deviceIdFromKeys(keys); + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); + + ws.on("error", reject); + + ws.on("message", (data: Buffer) => { + const msg = JSON.parse(data.toString()); + if (msg.type === "auth_challenge") { + const challenge = Buffer.from(msg.challenge, "hex"); + const sig = sign(challenge, keys.signSec); + ws.send( + JSON.stringify({ + type: "auth_response", + deviceId, + sig: sig.toString("hex"), + }), + ); + } else if (msg.type === "auth_ok") { + resolve({ ws, deviceId }); + } else if (msg.type === "error") { + reject(new Error(msg.error)); + } + }); + }); +} + +// Helper: wait for next message of a given type +function waitForMessage( + ws: WebSocket, + type: string, + timeoutMs = 5000, +): Promise> { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Timeout waiting for ${type}`)), timeoutMs); + const handler = (data: Buffer) => { + const msg = JSON.parse(data.toString()); + if (msg.type === type) { + clearTimeout(timer); + ws.off("message", handler); + resolve(msg); + } + }; + ws.on("message", handler); + }); +} + +describe("Integration: relay server end-to-end", () => { + let server: RelayServer; + let port: number; + + beforeAll(async () => { + server = new RelayServer({ port: 0 }); // random port + await server.start(); + port = server.port; + }); + + afterAll(async () => { + await server.stop(); + }); + + it("health check works", async () => { + const res = await fetch(`http://127.0.0.1:${port}/health`); + const body = await res.json(); + expect(body.status).toBe("ok"); + }); + + it("pairing flow: create and accept", async () => { + const alice = generateKeyPair(); + const bob = generateKeyPair(); + + // Alice initiates pairing + const createRes = await fetch(`http://127.0.0.1:${port}/pair`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + deviceId: deviceIdFromKeys(alice), + x25519PubKey: alice.encPub.toString("hex"), + }), + }); + expect(createRes.status).toBe(201); + const { pairingCode } = (await createRes.json()) as { pairingCode: string }; + expect(pairingCode).toHaveLength(6); + + // Bob accepts with the code + const acceptRes = await fetch(`http://127.0.0.1:${port}/pair/accept`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + deviceId: deviceIdFromKeys(bob), + x25519PubKey: bob.encPub.toString("hex"), + pairingCode, + }), + }); + expect(acceptRes.status).toBe(200); + const result = (await acceptRes.json()) as { + initiator: { deviceId: string; x25519PubKey: string }; + acceptor: { deviceId: string; x25519PubKey: string }; + }; + expect(result.initiator.deviceId).toBe(deviceIdFromKeys(alice)); + expect(result.acceptor.deviceId).toBe(deviceIdFromKeys(bob)); + }); + + it("rejects invalid pairing code", async () => { + const res = await fetch(`http://127.0.0.1:${port}/pair/accept`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + deviceId: "fake", + x25519PubKey: "fake", + pairingCode: "000000", + }), + }); + expect(res.status).toBe(404); + }); + + it("authenticates devices via WebSocket", async () => { + const alice = generateKeyPair(); + const { ws, deviceId } = await connectDevice(port, alice); + expect(deviceId).toBe(deviceIdFromKeys(alice)); + ws.close(); + }); + + it("rejects bad auth signatures", async () => { + const alice = generateKeyPair(); + const eve = generateKeyPair(); // wrong keys + + await expect( + new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); + ws.on("message", (data: Buffer) => { + const msg = JSON.parse(data.toString()); + if (msg.type === "auth_challenge") { + // Sign with wrong key + const challenge = Buffer.from(msg.challenge, "hex"); + const sig = sign(challenge, eve.signSec); + ws.send( + JSON.stringify({ + type: "auth_response", + deviceId: deviceIdFromKeys(alice), // claim to be alice + sig: sig.toString("hex"), + }), + ); + } + }); + ws.on("close", (code: number) => { + if (code === 4003) reject(new Error("Auth failed as expected")); + else resolve(); + }); + }), + ).rejects.toThrow("Auth failed"); + }); + + it("relays encrypted cookie sync between two devices", async () => { + const alice = generateKeyPair(); + const bob = generateKeyPair(); + + const aliceConn = await connectDevice(port, alice); + const bobConn = await connectDevice(port, bob); + + const aliceDeviceId = deviceIdFromKeys(alice); + const bobDeviceId = deviceIdFromKeys(bob); + + // Alice sends a cookie sync to Bob + const payload: CookieSyncPayload = { + action: "set", + cookies: [ + { + domain: "example.com", + name: "session", + value: "tok_abc123", + path: "/", + secure: true, + httpOnly: true, + sameSite: "lax", + expiresAt: null, + }, + ], + lamportTs: 1, + }; + + const envelope = buildEnvelope( + MESSAGE_TYPES.COOKIE_SYNC, + payload, + alice, + bob.encPub, + bobDeviceId, + ); + + // Bob listens for the message + const bobMessagePromise = waitForMessage(bobConn.ws, MESSAGE_TYPES.COOKIE_SYNC); + + // Alice sends + aliceConn.ws.send(JSON.stringify(envelope)); + + // Alice gets ACK + const ack = await waitForMessage(aliceConn.ws, MESSAGE_TYPES.ACK); + expect(ack.delivered).toBe(true); + + // Bob receives the encrypted envelope + const received = (await bobMessagePromise) as unknown as Envelope; + expect(received.from).toBe(aliceDeviceId); + expect(received.to).toBe(bobDeviceId); + + // Bob decrypts + const decrypted = openEnvelope(received, bob, alice.encPub); + expect(decrypted.action).toBe("set"); + expect(decrypted.cookies).toHaveLength(1); + expect(decrypted.cookies[0].domain).toBe("example.com"); + expect(decrypted.cookies[0].value).toBe("tok_abc123"); + + aliceConn.ws.close(); + bobConn.ws.close(); + }); + + it("queues messages for offline devices and delivers on reconnect", async () => { + const alice = generateKeyPair(); + const bob = generateKeyPair(); + const bobDeviceId = deviceIdFromKeys(bob); + + // Alice connects, Bob is offline + const aliceConn = await connectDevice(port, alice); + + const payload: CookieSyncPayload = { + action: "set", + cookies: [ + { + domain: "queued.com", + name: "token", + value: "queued_val", + path: "/", + secure: true, + httpOnly: false, + sameSite: "none", + expiresAt: null, + }, + ], + lamportTs: 1, + }; + + const envelope = buildEnvelope( + MESSAGE_TYPES.COOKIE_SYNC, + payload, + alice, + bob.encPub, + bobDeviceId, + ); + + // Send while Bob is offline + aliceConn.ws.send(JSON.stringify(envelope)); + + const ack = await waitForMessage(aliceConn.ws, MESSAGE_TYPES.ACK); + expect(ack.delivered).toBe(false); // queued, not delivered + + // Bob comes online — should receive the queued message + const bobMessagePromise = new Promise((resolve) => { + const bobWs = new WebSocket(`ws://127.0.0.1:${port}/ws`); + bobWs.on("message", (data: Buffer) => { + const msg = JSON.parse(data.toString()); + if (msg.type === "auth_challenge") { + const challenge = Buffer.from(msg.challenge, "hex"); + const sig = sign(challenge, bob.signSec); + bobWs.send( + JSON.stringify({ + type: "auth_response", + deviceId: bobDeviceId, + sig: sig.toString("hex"), + }), + ); + } else if (msg.type === MESSAGE_TYPES.COOKIE_SYNC) { + resolve(msg as unknown as Envelope); + bobWs.close(); + } + }); + }); + + const received = await bobMessagePromise; + const decrypted = openEnvelope(received, bob, alice.encPub); + expect(decrypted.cookies[0].value).toBe("queued_val"); + + aliceConn.ws.close(); + }); +}); diff --git a/tests/pairing.test.ts b/tests/pairing.test.ts new file mode 100644 index 0000000..b5b59e5 --- /dev/null +++ b/tests/pairing.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { PairingStore, generatePairingCode } from "../src/pairing/index.js"; + +describe("PairingStore", () => { + it("creates and finds a pairing session", () => { + const store = new PairingStore(); + const session = store.create("device-a-id", "device-a-x25519-pub"); + expect(session.pairingCode).toHaveLength(6); + expect(session.deviceId).toBe("device-a-id"); + + const found = store.find(session.pairingCode); + expect(found).not.toBeNull(); + expect(found!.deviceId).toBe("device-a-id"); + }); + + it("consumes a session (one-time use)", () => { + const store = new PairingStore(); + const session = store.create("d1", "pub1"); + + const consumed = store.consume(session.pairingCode); + expect(consumed).not.toBeNull(); + expect(consumed!.deviceId).toBe("d1"); + + // Second consume returns null + const again = store.consume(session.pairingCode); + expect(again).toBeNull(); + }); + + it("returns null for unknown code", () => { + const store = new PairingStore(); + expect(store.find("999999")).toBeNull(); + }); + + it("expires sessions after TTL", () => { + const store = new PairingStore(); + const session = store.create("d1", "pub1"); + + // Manually expire by setting expiresAt in the past + // We access the internal session via find and mutate it + const found = store.find(session.pairingCode); + if (found) { + (found as { expiresAt: number }).expiresAt = Date.now() - 1000; + } + + expect(store.find(session.pairingCode)).toBeNull(); + }); +}); + +describe("generatePairingCode", () => { + it("generates 6-digit codes", () => { + for (let i = 0; i < 20; i++) { + const code = generatePairingCode(); + expect(code).toHaveLength(6); + expect(/^\d{6}$/.test(code)).toBe(true); + } + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2ed8869 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..712cf39 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + testTimeout: 15_000, + }, +});