From 2ba049f576e96c64fe6b8fa0d87cbc7d6f2a8930 Mon Sep 17 00:00:00 2001 From: Alois Date: Tue, 14 Apr 2026 00:58:38 +0200 Subject: [PATCH] (feat): add first release --- .gitignore | 33 ++++ README.md | 69 +++++++ bun.lock | 219 ++++++++++++++++++++++ dist/crypto.d.ts | 18 ++ dist/crypto.js | 331 +++++++++++++++++++++++++++++++++ dist/index.d.ts | 62 +++++++ dist/index.js | 161 ++++++++++++++++ dist/schema.d.ts | 23 +++ dist/schema.js | 25 +++ dist/user.d.ts | 14 ++ dist/user.js | 18 ++ package.json | 33 ++++ src/crypto.ts | 439 ++++++++++++++++++++++++++++++++++++++++++++ src/index.html | 10 + src/index.ts | 254 +++++++++++++++++++++++++ src/schema.ts | 26 +++ src/user.ts | 34 ++++ tsconfig.build.json | 18 ++ tsconfig.json | 32 ++++ 19 files changed, 1819 insertions(+) create mode 100644 .gitignore create mode 100644 bun.lock create mode 100644 dist/crypto.d.ts create mode 100644 dist/crypto.js create mode 100644 dist/index.d.ts create mode 100644 dist/index.js create mode 100644 dist/schema.d.ts create mode 100644 dist/schema.js create mode 100644 dist/user.d.ts create mode 100644 dist/user.js create mode 100644 package.json create mode 100644 src/crypto.ts create mode 100644 src/index.html create mode 100644 src/index.ts create mode 100644 src/schema.ts create mode 100644 src/user.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfe1eec --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# dependencies (bun install) +node_modules + +# output +out +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/README.md b/README.md index 8e28f21..1fbdcdc 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,71 @@ # tauth-sdk +TypeScript SDK for TAuth-based login and transport session bootstrap. + +This SDK starts a local HTTP callback server, verifies a challenge, and opens an authenticated TTP transport client per `userId:sessionId`. + +## What this SDK does + +- Generates and verifies authentication challenges +- Starts callback and auth endpoints (`/callback`, `/auth`) +- Redirects users to your TAuth frontend URL +- Creates and tracks TTP clients in `clientMap` + +## Requirements + +- A modern version of Bun +- A domain with TXT record support +- App X448 key pair (`privateKey`, `publicKey`) (can be generated) + +## Install + +```bash +bun install +``` + +## Generate an app key pair + +Use the built-in helper once and store the keys securely: + +```ts +import { generateKeyPair } from "@tensamin/tauth-sdk"; + +const keys = generateKeyPair(); +console.log(keys.private); +console.log(keys.public); +``` + +## Add the TXT record + +Add a TXT record at `tauth.your.domain` with your app public key as the value (base64 format). This is used to verify that your app is authorized for your domain. + +## Basic usage + +```ts +import z from "zod"; +import { TAuthClient } from "@tensamin/tauth-sdk"; + +const client = new TAuthClient({ + frontendUrl: "http://localhost:3000", + identifier: "your-app-identifier", + privateKey: "", + publicKey: "", + redirectUrl: "http://localhost:7878/callback", + appData: z.object({ + my: z.string(), + cool: z.string(), + data: z.string(), + }), + httpServer: { + hostname: "localhost", + port: 7878, + }, +}); +``` + +## Endpoints exposed by the SDK + +- `GET /auth`: Redirects user to TAuth frontend +- `GET /callback`: Handles challenge flow and session bootstrap + +These endpoints need to be exposed behind some kind of http proxy to apply ssl diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..1a06ee7 --- /dev/null +++ b/bun.lock @@ -0,0 +1,219 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "tauth-sdk", + "dependencies": { + "@noble/curves": "^2.2.0", + "@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.9.tar.gz", + "@webtransport-bun/webtransport": "^0.3.0", + "zod": "^4.3.6", + }, + "devDependencies": { + "@types/bun": "^1.3.12", + "@types/node": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="], + + "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], + + "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], + + "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@noble/curves": ["@noble/curves@2.2.0", "", { "dependencies": { "@noble/hashes": "2.2.0" } }, "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ=="], + + "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + + "@tensamin/ttp-core": ["@tensamin/ttp-core@https://git.methanium.net/tensamin/ttp/archive/0.0.9.tar.gz", { "dependencies": { "@eslint/js": "^10.0.1", "@typescript-eslint/parser": "^8.58.1", "@webtransport-bun/webtransport": "^0.3.0", "globals": "^17.5.0", "typescript": "^6.0.2", "typescript-eslint": "^8.58.1", "zod": "^4.3.6" } }, "sha512-V9EK/t/tcBe1oNqzUMxtn41wc3UIS0mkLvGeR1mGfZaKn4d9bwwWQ2VmSmRSxodPYiQUJkziJV4fZvdzGjnzFw=="], + + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/type-utils": "8.58.1", "@typescript-eslint/utils": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.1", "@typescript-eslint/types": "^8.58.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1" } }, "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1", "@typescript-eslint/utils": "8.58.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.58.1", "", {}, "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.1", "@typescript-eslint/tsconfig-utils": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ=="], + + "@webtransport-bun/webtransport": ["@webtransport-bun/webtransport@0.3.0", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-/OX/TCgBD64n/0BjMNytObq5NK2pAM0KOoXBEw49l3sTmbVXNTPP1ZRPIdIyV43agM+mKtZBaV3Er3k3YoB6sw=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + + "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@10.2.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.4", "@eslint/config-helpers": "^0.5.4", "@eslint/core": "^1.2.0", "@eslint/plugin-kit": "^0.7.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA=="], + + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@17.5.0", "", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "typescript-eslint": ["typescript-eslint@8.58.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.58.1", "@typescript-eslint/parser": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1", "@typescript-eslint/utils": "8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg=="], + + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@tensamin/ttp-core/typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + } +} diff --git a/dist/crypto.d.ts b/dist/crypto.d.ts new file mode 100644 index 0000000..75bfc5c --- /dev/null +++ b/dist/crypto.d.ts @@ -0,0 +1,18 @@ +type Base64URLString = string; +export declare function encrypt(secret: string, plaintext: string): Promise; +/** + * Decrypts base64 ciphertext with a symmetric key derived from a hex shared secret. + * @param secret Hex-encoded shared secret. + * @param ciphertext Base64 ciphertext to decrypt. + * @returns Decrypted UTF-8 plaintext. + */ +export declare function decrypt(secret: string, ciphertext: Base64URLString | string): Promise; +/** + * Computes an X448 shared secret from local and peer key material. + * @param ownPrivateKey Local private key in raw/base64/base64url or PKCS#8-wrapped form. + * @param ownPublicKey Local public key in raw/base64/base64url or SPKI-wrapped form. + * @param otherPublicKey Peer public key in raw/base64/base64url or SPKI-wrapped form. + * @returns Hex-encoded shared secret, or a failure message when key material is missing/invalid. + */ +export declare function getSharedSecret(ownPrivateKey: string, ownPublicKey: string, otherPublicKey: string): Promise; +export {}; diff --git a/dist/crypto.js b/dist/crypto.js new file mode 100644 index 0000000..3ebd32c --- /dev/null +++ b/dist/crypto.js @@ -0,0 +1,331 @@ +const textEncoder = new TextEncoder(); +const crypto = globalThis.crypto; +export async function encrypt(secret, plaintext) { + const sharedSecret = new Uint8Array(secret.match(/.{1,2}/g).map((byte) => parseInt(byte, 16))); + const hkdfKey = await crypto.subtle.importKey("raw", sharedSecret, "HKDF", false, ["deriveBits"]); + const okm = await crypto.subtle.deriveBits({ + name: "HKDF", + hash: "SHA-256", + salt: new Uint8Array([]), + info: textEncoder.encode("x448-aes-gcm-no-overhead"), + }, hkdfKey, 44 * 8); + const okmBytes = new Uint8Array(okm); + const keyBytes = okmBytes.slice(0, 32); + const nonce = okmBytes.slice(32, 44); + const aesKey = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["encrypt"]); + const encryptedBuffer = await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, aesKey, textEncoder.encode(plaintext)); + return btoa(String.fromCharCode(...new Uint8Array(encryptedBuffer))); +} +/** + * Decrypts base64 ciphertext with a symmetric key derived from a hex shared secret. + * @param secret Hex-encoded shared secret. + * @param ciphertext Base64 ciphertext to decrypt. + * @returns Decrypted UTF-8 plaintext. + */ +export async function decrypt(secret, ciphertext) { + const sharedSecret = new Uint8Array(secret.match(/.{1,2}/g).map((byte) => parseInt(byte, 16))); + const ciphertextBytes = Uint8Array.from(atob(ciphertext), (c) => c.charCodeAt(0)); + const hkdfKey = await crypto.subtle.importKey("raw", sharedSecret, "HKDF", false, ["deriveBits"]); + const okm = await crypto.subtle.deriveBits({ + name: "HKDF", + hash: "SHA-256", + salt: new Uint8Array([]), + info: textEncoder.encode("x448-aes-gcm-no-overhead"), + }, hkdfKey, 44 * 8); + const okmBytes = new Uint8Array(okm); + const keyBytes = okmBytes.slice(0, 32); + const nonce = okmBytes.slice(32, 44); + const aesKey = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["decrypt"]); + const decryptedBuffer = await crypto.subtle.decrypt({ + name: "AES-GCM", + iv: nonce, + }, aesKey, ciphertextBytes); + return new TextDecoder().decode(decryptedBuffer); +} +/** + * Computes an X448 shared secret from local and peer key material. + * @param ownPrivateKey Local private key in raw/base64/base64url or PKCS#8-wrapped form. + * @param ownPublicKey Local public key in raw/base64/base64url or SPKI-wrapped form. + * @param otherPublicKey Peer public key in raw/base64/base64url or SPKI-wrapped form. + * @returns Hex-encoded shared secret, or a failure message when key material is missing/invalid. + */ +export async function getSharedSecret(ownPrivateKey, ownPublicKey, otherPublicKey) { + const otherJwk = { kty: "OKP", crv: "X448", x: otherPublicKey }; + const ownJwk = { + kty: "OKP", + crv: "X448", + x: ownPublicKey, + d: ownPrivateKey, + }; + /** + * Converts bytes to a lowercase hex string. + * @param u8 Byte array. + * @returns Hex string. + */ + const bytesToHex = (u8) => Array.from(u8, (b) => b.toString(16).padStart(2, "0")).join(""); + /** + * Decodes standard base64 text into bytes. + * @param s Base64 string. + * @returns Decoded bytes. + */ + const b64ToBytes = (s) => { + const bin = atob(s); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) + out[i] = bin.charCodeAt(i); + return out; + }; + /** + * Decodes URL-safe base64 text into bytes. + * @param s Base64url string. + * @returns Decoded bytes. + */ + const b64uToBytes = (s) => { + const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4); + return b64ToBytes(b64); + }; + /** + * Encodes bytes as URL-safe base64 without padding. + * @param u8 Byte array. + * @returns Base64url string. + */ + const bytesToB64u = (u8) => { + const b64 = btoa(String.fromCharCode(...u8)); + return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + }; + /** + * Decodes either base64 or base64url text into bytes. + * @param s Base64/base64url string. + * @returns Decoded bytes. + */ + const decodeBase64Auto = (s) => /[-_]/.test(s) ? b64uToBytes(s) : b64ToBytes(s); + /** + * Reads a DER TLV item from the provided offset. + * @param view DER-encoded bytes. + * @param off Start offset. + * @returns Parsed TLV metadata with tag, length, and boundaries. + */ + const readTLV = (view, off) => { + const tag = view[off]; + if (tag === undefined) + throw new Error("DER: truncated"); + off += 1; + const lenByte = view[off]; + if (lenByte === undefined) + throw new Error("DER: truncated"); + off += 1; + let len = lenByte; + if (lenByte & 0x80) { + const n = lenByte & 0x7f; + if (n === 0) + throw new Error("DER: indefinite length not supported"); + if (off + n > view.length) + throw new Error("DER: truncated length"); + len = 0; + for (let i = 0; i < n; i++) { + const b = view[off]; + if (b === undefined) + throw new Error("DER: truncated length"); + len = (len << 8) | b; + off += 1; + } + } + const start = off; + const end = off + len; + if (end > view.length) + throw new Error("DER: content truncated"); + return { tag, len, start, end }; + }; + /** + * Validates that a DER OID matches X448. + * @param view DER-encoded bytes. + * @param start Offset of the OID TLV. + * @returns True when the OID is X448. + */ + const ensureOidX448 = (view, start) => { + const oid = readTLV(view, start); + if (oid.tag !== 0x06) + return false; + const len = oid.end - oid.start; + if (len !== 3) + return false; + return (view[oid.start] === 0x2b && + view[oid.start + 1] === 0x65 && + view[oid.start + 2] === 0x6f); + }; + /** + * Extracts raw 56-byte X448 public key material from SPKI bytes. + * @param spkiBytes DER-encoded SPKI bytes. + * @returns Raw X448 public key bytes. + */ + const extractRawX448FromSPKI = (spkiBytes) => { + const view = spkiBytes; + const outer = readTLV(view, 0); + if (outer.tag !== 0x30) + throw new Error("SPKI: expected SEQUENCE"); + const alg = readTLV(view, outer.start); + if (alg.tag !== 0x30) + throw new Error("SPKI: expected AlgorithmIdentifier"); + if (!ensureOidX448(view, alg.start)) + throw new Error("SPKI: not X448"); + const bitstr = readTLV(view, alg.end); + if (bitstr.tag !== 0x03) + throw new Error("SPKI: expected BIT STRING"); + const unusedBits = view[bitstr.start]; + if (unusedBits !== 0x00) + throw new Error("SPKI: unexpected unused bits"); + const raw = view.subarray(bitstr.start + 1, bitstr.end); + if (raw.length !== 56) + throw new Error("SPKI: X448 public key must be 56 bytes"); + return raw; + }; + /** + * Extracts raw 56-byte X448 private key material from PKCS#8 bytes. + * @param pkcs8Bytes DER-encoded PKCS#8 bytes. + * @returns Raw X448 private key bytes. + */ + const extractRawX448FromPKCS8 = (pkcs8Bytes) => { + const view = pkcs8Bytes; + const outer = readTLV(view, 0); + if (outer.tag !== 0x30) + throw new Error("PKCS8: expected SEQUENCE"); + let off = outer.start; + const version = readTLV(view, off); + if (version.tag !== 0x02) + throw new Error("PKCS8: expected version INTEGER"); + off = version.end; + const alg = readTLV(view, off); + if (alg.tag !== 0x30) + throw new Error("PKCS8: expected AlgorithmIdentifier"); + if (!ensureOidX448(view, alg.start)) + throw new Error("PKCS8: not X448"); + off = alg.end; + const priv = readTLV(view, off); + if (priv.tag !== 0x04) + throw new Error("PKCS8: expected privateKey OCTET STRING"); + let raw = view.subarray(priv.start, priv.end); + // Some encoders nest another OCTET STRING inside + if (raw[0] === 0x04) { + const inner = readTLV(raw, 0); + if (inner.tag === 0x04) { + raw = raw.subarray(inner.start, inner.end); + } + } + if (raw.length !== 56) + throw new Error("PKCS8: X448 private key must be 56 bytes"); + return raw; + }; + /** + * Normalizes X448 JWK fields into raw base64url key material. + * @param jwk Candidate JWK. + * @param label Error label for diagnostics. + * @returns Normalized JWK suitable for WebCrypto import. + */ + const normalizeOkpX448Jwk = (jwk, label) => { + if (!jwk || jwk.kty !== "OKP" || jwk.crv !== "X448") { + throw new Error(`${label}: expected OKP JWK with crv "X448"`); + } + const out = { ...jwk }; + if (out.x) { + const xBytes = decodeBase64Auto(out.x); + let rawX; + try { + rawX = extractRawX448FromSPKI(xBytes); + } + catch { + if (xBytes.length !== 56) { + throw new Error(`${label}: "x" is not a valid X448 SPKI or raw 56-byte key`); + } + rawX = xBytes; + } + out.x = bytesToB64u(rawX); + } + if (out.d) { + const dBytes = decodeBase64Auto(out.d); + let rawD; + try { + rawD = extractRawX448FromPKCS8(dBytes); + } + catch { + if (dBytes.length !== 56) { + throw new Error(`${label}: "d" is not a valid X448 PKCS#8 or raw 56-byte key`); + } + rawD = dBytes; + } + out.d = bytesToB64u(rawD); + } + return out; + }; + /** + * Returns WebCrypto subtle API when available. + * @returns SubtleCrypto instance or undefined. + */ + const getSubtle = () => globalThis.crypto?.subtle; + { + /* + const hkdfAesGcmFromShared = async ( + sharedSecret: BufferSource, + infoStr: string + ): Promise => { + const subtle = getSubtle(); + if (!subtle) throw new Error("WebCrypto subtle not available"); + const info = textEncoder.encode(infoStr); + const baseKey = await subtle.importKey( + "raw", + sharedSecret, + "HKDF", + false, + ["deriveKey"] + ); + return await subtle.deriveKey( + { + name: "HKDF", + hash: "SHA-256", + salt: new Uint8Array(0), + info, + }, + baseKey, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); + }; + */ + } + const myJwk = normalizeOkpX448Jwk(ownJwk, "own_jwk"); + const peerJwk = normalizeOkpX448Jwk(otherJwk, "other_jwk"); + const subtle = getSubtle(); + //const infoStr = `ECDH-X448-AES-GCM-v1|my=${myJwk.x}|peer=${peerJwk.x}`; + if (subtle) { + const algorithms = [{ name: "ECDH", namedCurve: "X448" }, { name: "X448" }]; + for (const algorithm of algorithms) { + try { + const [myPriv, peerPub] = await Promise.all([ + subtle.importKey("jwk", myJwk, algorithm, false, ["deriveBits"]), + subtle.importKey("jwk", peerJwk, algorithm, false, []), + ]); + const sharedBits = await subtle.deriveBits({ name: algorithm.name, public: peerPub }, myPriv, 448); + const sharedSecret = new Uint8Array(sharedBits); + //const aeadKey = await hkdfAesGcmFromShared(sharedSecret, infoStr); + return bytesToHex(sharedSecret); + } + catch { + // Browser doesn't support this algorithm, try next or fall through to software fallback + } + } + } + const { d: dMyB64u } = myJwk; + //const { x: xMyB64u, d: dMyB64u } = myJwk; + const { x: xPeerB64u } = peerJwk; + if (!dMyB64u || !xPeerB64u) { + return "Failed to get shared secret due to missing keys"; + } + const [dRaw, xRawPeer] = [b64uToBytes(dMyB64u), b64uToBytes(xPeerB64u)]; + if (dRaw.length !== 56 || xRawPeer.length !== 56) { + return "Failed to get shared secret due to invalid key lengths"; + } + const { x448 } = await import("@noble/curves/ed448.js"); + const sharedSecret = new Uint8Array(x448.getSharedSecret(dRaw, xRawPeer)); + //const aeadKey = await hkdfAesGcmFromShared(sharedSecret, infoStr); + return bytesToHex(sharedSecret); +} diff --git a/dist/index.d.ts b/dist/index.d.ts new file mode 100644 index 0000000..20f08f8 --- /dev/null +++ b/dist/index.d.ts @@ -0,0 +1,62 @@ +import { type TransportClient, READY_STATE } from "@tensamin/ttp-core"; +import { createSchema } from "./schema"; +import type { ZodObject } from "zod"; +/** + * @param redirectUrl The URL should point to the TAuth HTTP Server at http://hostname:port/callback + */ +export declare class TAuthClient { + frontendUrl: string; + identifier: string; + privateKey: string; + publicKey: string; + redirectUrl: URL; + schema: ReturnType; + httpServer: { + port: number; + hostname: string; + }; + clientMap: Map; + response: ZodObject<{ + challenge: import("zod").ZodBase64; + public_key: import("zod").ZodBase64; + }, import("zod/v4/core").$strip>; + }; + challenge_response: { + request: ZodObject<{ + challenge: import("zod").ZodBase64; + }, import("zod/v4/core").$strip>; + response: ZodObject<{ + app_data: ZodObject; + }, import("zod/v4/core").$strip>; + }; + }>>; + constructor({ frontendUrl, identifier, privateKey, publicKey, redirectUrl, appData, httpServer, htmlSuccessPage, }: { + frontendUrl: string; + identifier: string; + privateKey: string; + publicKey: string; + redirectUrl: string; + appData: ZodObject; + httpServer?: { + port: number; + hostname: string; + }; + htmlSuccessPage?: string; + }); + generateChallenge(userId: number): Promise; + solveChallenge(userId: number, challenge: string): Promise; + createTTP(userId: number, sessionId: number, omikronUrl: string): void; + generateLink(challenge?: string): string; +} +export declare function getFriendlyReadyState(stateIndex: number): keyof typeof READY_STATE; +export declare function generateKeyPair(): { + private: string; + public: string; +}; diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..40ccccc --- /dev/null +++ b/dist/index.js @@ -0,0 +1,161 @@ +import { createTransportClient, READY_STATE, } from "@tensamin/ttp-core"; +import { createSchema } from "./schema"; +import { x448 } from "@noble/curves/ed448.js"; +import Bun from "bun"; +import { get } from "./user"; +import { decrypt, encrypt, getSharedSecret } from "./crypto"; +import { readFile } from "node:fs/promises"; +/** + * @param redirectUrl The URL should point to the TAuth HTTP Server at http://hostname:port/callback + */ +export class TAuthClient { + frontendUrl; + identifier; + privateKey; + publicKey; + redirectUrl; + schema; + httpServer; + clientMap = new Map(); + constructor({ frontendUrl, identifier, privateKey, publicKey, redirectUrl, appData, httpServer, htmlSuccessPage, }) { + this.frontendUrl = frontendUrl; + this.identifier = identifier; + this.privateKey = privateKey; + this.publicKey = publicKey; + this.redirectUrl = new URL(redirectUrl); + this.schema = createSchema(appData); + this.httpServer = httpServer ?? { + port: 7878, + hostname: "localhost", + }; + Bun.serve({ + hostname: this.httpServer.hostname, + port: this.httpServer.port, + fetch: async (request) => { + const url = new URL(request.url); + switch (url.pathname) { + case "/callback": { + const userId = Number(url.searchParams.get("userId")); + if (!userId) { + return new Response("Missing userId", { status: 400 }); + } + const potentialChallenge = url.searchParams.get("challenge"); + if (!potentialChallenge) { + const challenge = await this.generateChallenge(userId); + const newLink = this.generateLink(challenge); + return new Response(null, { + status: 302, + headers: { + Location: newLink, + }, + }); + } + const originalChallenge = url.searchParams.get("originalChallenge"); + if (!originalChallenge) { + return new Response("Missing originalChallenge", { status: 400 }); + } + const potentialSessionId = Number(url.searchParams.get("sessionId")); + if (!potentialSessionId || isNaN(potentialSessionId)) { + return new Response("Invalid or missing sessionId", { + status: 400, + }); + } + if ((await this.solveChallenge(userId, originalChallenge)) === + potentialChallenge) { + const { ip_address } = await fetch("https://omega.tensamin.net/api/get/omikron/" + userId).then((res) => res.json()); + this.createTTP(userId, potentialSessionId, ip_address); + return new Response(htmlSuccessPage || (await readFile("./index.html")), { + headers: { + "Content-Type": "text/html", + }, + status: 200, + }); + } + else { + return new Response("Failed to solve challenge", { status: 400 }); + } + } + case "/auth": { + return new Response(null, { + status: 302, + headers: { + Location: this.generateLink(), + }, + }); + } + default: { + return new Response("Not Found", { status: 404 }); + } + } + }, + }); + console.log(`[tauth] HTTP Server running at http://${this.httpServer.hostname}:${this.httpServer.port}`); + } + async generateChallenge(userId) { + const userData = await get(userId); + if (!userData) { + throw new Error("User not found"); + } + const sharedSecret = await getSharedSecret(this.privateKey, this.publicKey, userData.public_key); + const encrypted = await encrypt(sharedSecret, crypto.randomUUID()); + const decrypted = await decrypt(sharedSecret, encrypted); + console.log(`[tauth] Generated challenge for user ${userId}: ${decrypted} (encrypted: ${encrypted})`); + return Array.from(atob(encrypted)) + .map((chat) => chat.charCodeAt(0).toString(16).padStart(2, "0")) + .join(""); + } + async solveChallenge(userId, challenge) { + const userData = await get(userId); + if (!userData) { + throw new Error("User not found"); + } + const sharedSecret = await getSharedSecret(this.privateKey, this.publicKey, userData.public_key); + return await decrypt(sharedSecret, challenge); + } + createTTP(userId, sessionId, omikronUrl) { + console.log(`[tauth] Creating TTP client for user ${userId} with session ${sessionId} at ${omikronUrl}`); + this.clientMap.set(`${userId}:${sessionId}`, createTransportClient(this.schema, { + url: omikronUrl, + onReadyStateChange: (stateIndex) => { + const state = getFriendlyReadyState(stateIndex); + console.log(state); + if (state === "OPEN") { + this.clientMap + .get(`${userId}:${sessionId}`) + ?.send("identification", { + app_identifier: this.identifier, + app_session_id: sessionId, + app_public_key: this.publicKey, + user_id: userId, + }); + } + }, + })); + this.clientMap.get(`${userId}:${sessionId}`)?.connect(); + } + generateLink(challenge) { + return new URL(`?identifier=${this.identifier}&redirect=${this.redirectUrl}${challenge ? `&challenge=${challenge}` : ""}`, this.frontendUrl).toString(); + } +} +export function getFriendlyReadyState(stateIndex) { + return Object.keys(READY_STATE).find((key) => READY_STATE[key] === stateIndex); +} +export function generateKeyPair() { + function toBase64(bytes) { + if (typeof Buffer !== "undefined") { + return Buffer.from(bytes).toString("base64"); + } + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); + } + const priv = new Uint8Array(56); + crypto.getRandomValues(priv); + const pub = x448.getPublicKey(priv); + return { + private: toBase64(priv), + public: toBase64(pub), + }; +} diff --git a/dist/schema.d.ts b/dist/schema.d.ts new file mode 100644 index 0000000..75cb45c --- /dev/null +++ b/dist/schema.d.ts @@ -0,0 +1,23 @@ +import z, { ZodObject } from "zod"; +export declare function createSchema(appData: ZodObject): { + identification: { + request: z.ZodObject<{ + app_identifier: z.ZodString; + app_session_id: z.ZodNumber; + app_public_key: z.ZodString; + user_id: z.ZodNumber; + }, z.core.$strip>; + response: z.ZodObject<{ + challenge: z.ZodBase64; + public_key: z.ZodBase64; + }, z.core.$strip>; + }; + challenge_response: { + request: z.ZodObject<{ + challenge: z.ZodBase64; + }, z.core.$strip>; + response: z.ZodObject<{ + app_data: z.ZodObject; + }, z.core.$strip>; + }; +}; diff --git a/dist/schema.js b/dist/schema.js new file mode 100644 index 0000000..90fa62a --- /dev/null +++ b/dist/schema.js @@ -0,0 +1,25 @@ +import z, { ZodObject } from "zod"; +export function createSchema(appData) { + return { + identification: { + request: z.object({ + app_identifier: z.string(), + app_session_id: z.number(), + app_public_key: z.string(), + user_id: z.number(), + }), + response: z.object({ + challenge: z.base64(), + public_key: z.base64(), + }), + }, + challenge_response: { + request: z.object({ + challenge: z.base64(), + }), + response: z.object({ + app_data: appData, + }), + }, + }; +} diff --git a/dist/user.d.ts b/dist/user.d.ts new file mode 100644 index 0000000..b4a47a8 --- /dev/null +++ b/dist/user.d.ts @@ -0,0 +1,14 @@ +export type User = { + status: string; + username: string; + public_key: string; + user_id: number; + iota_id: number; + sub_level: number; + sub_end: number; + display: string; + status_message: string; + about: string; +}; +export declare const userCacheMap: Map; +export declare function get(userId: number): Promise; diff --git a/dist/user.js b/dist/user.js new file mode 100644 index 0000000..2be1b4b --- /dev/null +++ b/dist/user.js @@ -0,0 +1,18 @@ +export const userCacheMap = new Map(); +export async function get(userId) { + try { + const cachedUser = userCacheMap.get(userId); + if (cachedUser) { + return cachedUser; + } + const fetchedUser = await fetch("https://omega.tensamin.net/api/get/user/" + userId).then((res) => res.json()); + if (fetchedUser) { + userCacheMap.set(userId, fetchedUser); + } + return fetchedUser; + } + catch (error) { + console.error("Error fetching user data for userId:", userId, error); + return undefined; + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..db2a14b --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "@tensamin/tauth-sdk", + "version": "0.0.1", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.build.json" + }, + "devDependencies": { + "@types/bun": "^1.3.12", + "@types/node": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@noble/curves": "^2.2.0", + "@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.9.tar.gz", + "@webtransport-bun/webtransport": "^0.3.0", + "zod": "^4.3.6" + } +} diff --git a/src/crypto.ts b/src/crypto.ts new file mode 100644 index 0000000..7738a2b --- /dev/null +++ b/src/crypto.ts @@ -0,0 +1,439 @@ +type Base64URLString = string; + +type JWK = { + kty: string; + crv: string; + x?: string; + d?: string; +}; + +const textEncoder = new TextEncoder(); +const crypto = globalThis.crypto; + +export async function encrypt( + secret: string, + plaintext: string, +): Promise { + const sharedSecret = new Uint8Array( + secret.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)), + ); + + const hkdfKey = await crypto.subtle.importKey( + "raw", + sharedSecret, + "HKDF", + false, + ["deriveBits"], + ); + + const okm = await crypto.subtle.deriveBits( + { + name: "HKDF", + hash: "SHA-256", + salt: new Uint8Array([]), + info: textEncoder.encode("x448-aes-gcm-no-overhead"), + }, + hkdfKey, + 44 * 8, + ); + + const okmBytes = new Uint8Array(okm); + const keyBytes = okmBytes.slice(0, 32); + const nonce = okmBytes.slice(32, 44); + + const aesKey = await crypto.subtle.importKey( + "raw", + keyBytes, + { name: "AES-GCM" }, + false, + ["encrypt"], + ); + + const encryptedBuffer = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: nonce }, + aesKey, + textEncoder.encode(plaintext), + ); + + return btoa(String.fromCharCode(...new Uint8Array(encryptedBuffer))); +} + +/** + * Decrypts base64 ciphertext with a symmetric key derived from a hex shared secret. + * @param secret Hex-encoded shared secret. + * @param ciphertext Base64 ciphertext to decrypt. + * @returns Decrypted UTF-8 plaintext. + */ +export async function decrypt( + secret: string, + ciphertext: Base64URLString | string, +): Promise { + const sharedSecret = new Uint8Array( + secret.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)), + ); + + const ciphertextBytes = Uint8Array.from(atob(ciphertext), (c) => + c.charCodeAt(0), + ); + + const hkdfKey = await crypto.subtle.importKey( + "raw", + sharedSecret, + "HKDF", + false, + ["deriveBits"], + ); + + const okm = await crypto.subtle.deriveBits( + { + name: "HKDF", + hash: "SHA-256", + salt: new Uint8Array([]), + info: textEncoder.encode("x448-aes-gcm-no-overhead"), + }, + hkdfKey, + 44 * 8, + ); + + const okmBytes = new Uint8Array(okm); + const keyBytes = okmBytes.slice(0, 32); + const nonce = okmBytes.slice(32, 44); + + const aesKey = await crypto.subtle.importKey( + "raw", + keyBytes, + { name: "AES-GCM" }, + false, + ["decrypt"], + ); + + const decryptedBuffer = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: nonce, + }, + aesKey, + ciphertextBytes, + ); + + return new TextDecoder().decode(decryptedBuffer); +} + +/** + * Computes an X448 shared secret from local and peer key material. + * @param ownPrivateKey Local private key in raw/base64/base64url or PKCS#8-wrapped form. + * @param ownPublicKey Local public key in raw/base64/base64url or SPKI-wrapped form. + * @param otherPublicKey Peer public key in raw/base64/base64url or SPKI-wrapped form. + * @returns Hex-encoded shared secret, or a failure message when key material is missing/invalid. + */ +export async function getSharedSecret( + ownPrivateKey: string, + ownPublicKey: string, + otherPublicKey: string, +): Promise { + const otherJwk: JWK = { kty: "OKP", crv: "X448", x: otherPublicKey }; + const ownJwk: JWK = { + kty: "OKP", + crv: "X448", + x: ownPublicKey, + d: ownPrivateKey, + }; + + /** + * Converts bytes to a lowercase hex string. + * @param u8 Byte array. + * @returns Hex string. + */ + const bytesToHex = (u8: Uint8Array): string => + Array.from(u8, (b) => b.toString(16).padStart(2, "0")).join(""); + + /** + * Decodes standard base64 text into bytes. + * @param s Base64 string. + * @returns Decoded bytes. + */ + const b64ToBytes = (s: Base64URLString): Uint8Array => { + const bin = atob(s); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + }; + + /** + * Decodes URL-safe base64 text into bytes. + * @param s Base64url string. + * @returns Decoded bytes. + */ + const b64uToBytes = (s: Base64URLString): Uint8Array => { + const b64 = + s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4); + return b64ToBytes(b64); + }; + + /** + * Encodes bytes as URL-safe base64 without padding. + * @param u8 Byte array. + * @returns Base64url string. + */ + const bytesToB64u = (u8: Uint8Array): string => { + const b64 = btoa(String.fromCharCode(...u8)); + return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + }; + + /** + * Decodes either base64 or base64url text into bytes. + * @param s Base64/base64url string. + * @returns Decoded bytes. + */ + const decodeBase64Auto = (s: string): Uint8Array => + /[-_]/.test(s) ? b64uToBytes(s) : b64ToBytes(s); + + /** + * Reads a DER TLV item from the provided offset. + * @param view DER-encoded bytes. + * @param off Start offset. + * @returns Parsed TLV metadata with tag, length, and boundaries. + */ + const readTLV = (view: Uint8Array, off: number) => { + const tag = view[off]; + if (tag === undefined) throw new Error("DER: truncated"); + off += 1; + + const lenByte = view[off]; + if (lenByte === undefined) throw new Error("DER: truncated"); + off += 1; + + let len = lenByte; + if (lenByte & 0x80) { + const n = lenByte & 0x7f; + if (n === 0) throw new Error("DER: indefinite length not supported"); + if (off + n > view.length) throw new Error("DER: truncated length"); + len = 0; + for (let i = 0; i < n; i++) { + const b = view[off]; + if (b === undefined) throw new Error("DER: truncated length"); + len = (len << 8) | b; + off += 1; + } + } + const start = off; + const end = off + len; + if (end > view.length) throw new Error("DER: content truncated"); + return { tag, len, start, end }; + }; + + /** + * Validates that a DER OID matches X448. + * @param view DER-encoded bytes. + * @param start Offset of the OID TLV. + * @returns True when the OID is X448. + */ + const ensureOidX448 = (view: Uint8Array, start: number): boolean => { + const oid = readTLV(view, start); + if (oid.tag !== 0x06) return false; + const len = oid.end - oid.start; + if (len !== 3) return false; + return ( + view[oid.start] === 0x2b && + view[oid.start + 1] === 0x65 && + view[oid.start + 2] === 0x6f + ); + }; + + /** + * Extracts raw 56-byte X448 public key material from SPKI bytes. + * @param spkiBytes DER-encoded SPKI bytes. + * @returns Raw X448 public key bytes. + */ + const extractRawX448FromSPKI = (spkiBytes: Uint8Array): Uint8Array => { + const view = spkiBytes; + const outer = readTLV(view, 0); + if (outer.tag !== 0x30) throw new Error("SPKI: expected SEQUENCE"); + const alg = readTLV(view, outer.start); + if (alg.tag !== 0x30) throw new Error("SPKI: expected AlgorithmIdentifier"); + if (!ensureOidX448(view, alg.start)) throw new Error("SPKI: not X448"); + const bitstr = readTLV(view, alg.end); + if (bitstr.tag !== 0x03) throw new Error("SPKI: expected BIT STRING"); + const unusedBits = view[bitstr.start]; + if (unusedBits !== 0x00) throw new Error("SPKI: unexpected unused bits"); + const raw = view.subarray(bitstr.start + 1, bitstr.end); + if (raw.length !== 56) + throw new Error("SPKI: X448 public key must be 56 bytes"); + return raw; + }; + + /** + * Extracts raw 56-byte X448 private key material from PKCS#8 bytes. + * @param pkcs8Bytes DER-encoded PKCS#8 bytes. + * @returns Raw X448 private key bytes. + */ + const extractRawX448FromPKCS8 = (pkcs8Bytes: Uint8Array): Uint8Array => { + const view = pkcs8Bytes; + const outer = readTLV(view, 0); + if (outer.tag !== 0x30) throw new Error("PKCS8: expected SEQUENCE"); + let off = outer.start; + + const version = readTLV(view, off); + if (version.tag !== 0x02) + throw new Error("PKCS8: expected version INTEGER"); + off = version.end; + + const alg = readTLV(view, off); + if (alg.tag !== 0x30) + throw new Error("PKCS8: expected AlgorithmIdentifier"); + if (!ensureOidX448(view, alg.start)) throw new Error("PKCS8: not X448"); + off = alg.end; + + const priv = readTLV(view, off); + if (priv.tag !== 0x04) + throw new Error("PKCS8: expected privateKey OCTET STRING"); + let raw = view.subarray(priv.start, priv.end); + + // Some encoders nest another OCTET STRING inside + if (raw[0] === 0x04) { + const inner = readTLV(raw, 0); + if (inner.tag === 0x04) { + raw = raw.subarray(inner.start, inner.end); + } + } + if (raw.length !== 56) + throw new Error("PKCS8: X448 private key must be 56 bytes"); + return raw; + }; + + /** + * Normalizes X448 JWK fields into raw base64url key material. + * @param jwk Candidate JWK. + * @param label Error label for diagnostics. + * @returns Normalized JWK suitable for WebCrypto import. + */ + const normalizeOkpX448Jwk = (jwk: JWK, label: string): JWK => { + if (!jwk || jwk.kty !== "OKP" || jwk.crv !== "X448") { + throw new Error(`${label}: expected OKP JWK with crv "X448"`); + } + const out = { ...jwk }; + + if (out.x) { + const xBytes = decodeBase64Auto(out.x); + let rawX: Uint8Array; + try { + rawX = extractRawX448FromSPKI(xBytes); + } catch { + if (xBytes.length !== 56) { + throw new Error( + `${label}: "x" is not a valid X448 SPKI or raw 56-byte key`, + ); + } + rawX = xBytes; + } + out.x = bytesToB64u(rawX); + } + + if (out.d) { + const dBytes = decodeBase64Auto(out.d); + let rawD: Uint8Array; + try { + rawD = extractRawX448FromPKCS8(dBytes); + } catch { + if (dBytes.length !== 56) { + throw new Error( + `${label}: "d" is not a valid X448 PKCS#8 or raw 56-byte key`, + ); + } + rawD = dBytes; + } + out.d = bytesToB64u(rawD); + } + + return out; + }; + + /** + * Returns WebCrypto subtle API when available. + * @returns SubtleCrypto instance or undefined. + */ + const getSubtle = () => globalThis.crypto?.subtle; + + { + /* + const hkdfAesGcmFromShared = async ( + sharedSecret: BufferSource, + infoStr: string + ): Promise => { + const subtle = getSubtle(); + if (!subtle) throw new Error("WebCrypto subtle not available"); + const info = textEncoder.encode(infoStr); + const baseKey = await subtle.importKey( + "raw", + sharedSecret, + "HKDF", + false, + ["deriveKey"] + ); + return await subtle.deriveKey( + { + name: "HKDF", + hash: "SHA-256", + salt: new Uint8Array(0), + info, + }, + baseKey, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); + }; + */ + } + + const myJwk: JWK = normalizeOkpX448Jwk(ownJwk, "own_jwk"); + const peerJwk: JWK = normalizeOkpX448Jwk(otherJwk, "other_jwk"); + + const subtle = getSubtle(); + //const infoStr = `ECDH-X448-AES-GCM-v1|my=${myJwk.x}|peer=${peerJwk.x}`; + + if (subtle) { + const algorithms = [{ name: "ECDH", namedCurve: "X448" }, { name: "X448" }]; + + for (const algorithm of algorithms) { + try { + const [myPriv, peerPub] = await Promise.all([ + subtle.importKey("jwk", myJwk, algorithm, false, ["deriveBits"]), + subtle.importKey("jwk", peerJwk, algorithm, false, []), + ]); + + const sharedBits = await subtle.deriveBits( + { name: algorithm.name, public: peerPub }, + myPriv, + 448, + ); + + const sharedSecret = new Uint8Array(sharedBits); + //const aeadKey = await hkdfAesGcmFromShared(sharedSecret, infoStr); + + return bytesToHex(sharedSecret); + } catch { + // Browser doesn't support this algorithm, try next or fall through to software fallback + } + } + } + + const { d: dMyB64u } = myJwk; + //const { x: xMyB64u, d: dMyB64u } = myJwk; + const { x: xPeerB64u } = peerJwk; + + if (!dMyB64u || !xPeerB64u) { + return "Failed to get shared secret due to missing keys"; + } + + const [dRaw, xRawPeer] = [b64uToBytes(dMyB64u), b64uToBytes(xPeerB64u)]; + if (dRaw.length !== 56 || xRawPeer.length !== 56) { + return "Failed to get shared secret due to invalid key lengths"; + } + + const { x448 } = await import("@noble/curves/ed448.js"); + const sharedSecret = new Uint8Array(x448.getSharedSecret(dRaw, xRawPeer)); + //const aeadKey = await hkdfAesGcmFromShared(sharedSecret, infoStr); + + return bytesToHex(sharedSecret); +} diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..b7e8811 --- /dev/null +++ b/src/index.html @@ -0,0 +1,10 @@ + Login successful

Login successful

You can close this page now.

\ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d5a70ba --- /dev/null +++ b/src/index.ts @@ -0,0 +1,254 @@ +import { + createTransportClient, + type TransportClient, + READY_STATE, +} from "@tensamin/ttp-core"; +import { createSchema } from "./schema"; +import type { ZodObject } from "zod"; +import { x448 } from "@noble/curves/ed448.js"; +import Bun from "bun"; +import { get } from "./user"; +import { decrypt, encrypt, getSharedSecret } from "./crypto"; +import { readFile } from "node:fs/promises"; + +/** + * @param redirectUrl The URL should point to the TAuth HTTP Server at http://hostname:port/callback + */ +export class TAuthClient { + frontendUrl: string; + identifier: string; + privateKey: string; + publicKey: string; + redirectUrl: URL; + schema: ReturnType; + httpServer: { + port: number; + hostname: string; + }; + clientMap = new Map>(); + + constructor({ + frontendUrl, + identifier, + privateKey, + publicKey, + redirectUrl, + appData, + httpServer, + htmlSuccessPage, + }: { + frontendUrl: string; + identifier: string; + privateKey: string; + publicKey: string; + redirectUrl: string; + appData: ZodObject; + httpServer?: { + port: number; + hostname: string; + }; + htmlSuccessPage?: string; + }) { + this.frontendUrl = frontendUrl; + this.identifier = identifier; + this.privateKey = privateKey; + this.publicKey = publicKey; + this.redirectUrl = new URL(redirectUrl); + this.schema = createSchema(appData); + this.httpServer = httpServer ?? { + port: 7878, + hostname: "localhost", + }; + + Bun.serve({ + hostname: this.httpServer.hostname, + port: this.httpServer.port, + fetch: async (request) => { + const url = new URL(request.url); + switch (url.pathname) { + case "/callback": { + const userId = Number(url.searchParams.get("userId")); + if (!userId) { + return new Response("Missing userId", { status: 400 }); + } + + const potentialChallenge = url.searchParams.get("challenge"); + if (!potentialChallenge) { + const challenge = await this.generateChallenge(userId); + const newLink = this.generateLink(challenge); + return new Response(null, { + status: 302, + headers: { + Location: newLink, + }, + }); + } + + const originalChallenge = url.searchParams.get("originalChallenge"); + if (!originalChallenge) { + return new Response("Missing originalChallenge", { status: 400 }); + } + + const potentialSessionId = Number( + url.searchParams.get("sessionId"), + ); + if (!potentialSessionId || isNaN(potentialSessionId)) { + return new Response("Invalid or missing sessionId", { + status: 400, + }); + } + + if ( + (await this.solveChallenge(userId, originalChallenge)) === + potentialChallenge + ) { + const { ip_address } = await fetch( + "https://omega.tensamin.net/api/get/omikron/" + userId, + ).then((res) => res.json()); + + this.createTTP(userId, potentialSessionId, ip_address); + + return new Response( + htmlSuccessPage || (await readFile("./index.html")), + { + headers: { + "Content-Type": "text/html", + }, + status: 200, + }, + ); + } else { + return new Response("Failed to solve challenge", { status: 400 }); + } + } + + case "/auth": { + return new Response(null, { + status: 302, + headers: { + Location: this.generateLink(), + }, + }); + } + + default: { + return new Response("Not Found", { status: 404 }); + } + } + }, + }); + + console.log( + `[tauth] HTTP Server running at http://${this.httpServer.hostname}:${this.httpServer.port}`, + ); + } + + async generateChallenge(userId: number): Promise { + const userData = await get(userId); + if (!userData) { + throw new Error("User not found"); + } + + const sharedSecret = await getSharedSecret( + this.privateKey, + this.publicKey, + userData.public_key, + ); + + const encrypted = await encrypt(sharedSecret, crypto.randomUUID()); + const decrypted = await decrypt(sharedSecret, encrypted); + console.log( + `[tauth] Generated challenge for user ${userId}: ${decrypted} (encrypted: ${encrypted})`, + ); + + return Array.from(atob(encrypted)) + .map((chat) => chat.charCodeAt(0).toString(16).padStart(2, "0")) + .join(""); + } + + async solveChallenge(userId: number, challenge: string) { + const userData = await get(userId); + if (!userData) { + throw new Error("User not found"); + } + + const sharedSecret = await getSharedSecret( + this.privateKey, + this.publicKey, + userData.public_key, + ); + + return await decrypt(sharedSecret, challenge); + } + + createTTP(userId: number, sessionId: number, omikronUrl: string) { + console.log( + `[tauth] Creating TTP client for user ${userId} with session ${sessionId} at ${omikronUrl}`, + ); + + this.clientMap.set( + `${userId}:${sessionId}`, + createTransportClient(this.schema, { + url: omikronUrl, + onReadyStateChange: (stateIndex) => { + const state = getFriendlyReadyState(stateIndex); + console.log(state); + if (state === "OPEN") { + this.clientMap + .get(`${userId}:${sessionId}`) + ?.send("identification", { + app_identifier: this.identifier, + app_session_id: sessionId, + app_public_key: this.publicKey, + user_id: userId, + }); + } + }, + }), + ); + + this.clientMap.get(`${userId}:${sessionId}`)?.connect(); + } + + generateLink(challenge?: string): string { + return new URL( + `?identifier=${this.identifier}&redirect=${this.redirectUrl}${challenge ? `&challenge=${challenge}` : ""}`, + this.frontendUrl, + ).toString(); + } +} + +export function getFriendlyReadyState( + stateIndex: number, +): keyof typeof READY_STATE { + return Object.keys(READY_STATE).find( + (key) => READY_STATE[key as keyof typeof READY_STATE] === stateIndex, + ) as keyof typeof READY_STATE; +} + +export function generateKeyPair(): { private: string; public: string } { + function toBase64(bytes: Uint8Array): string { + if (typeof Buffer !== "undefined") { + return Buffer.from(bytes).toString("base64"); + } + + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + + return btoa(binary); + } + + const priv = new Uint8Array(56); + crypto.getRandomValues(priv); + const pub = x448.getPublicKey(priv); + return { + private: toBase64(priv), + public: toBase64(pub), + }; +} + +export * from "./crypto"; +export * from "./schema"; +export * from "./user"; \ No newline at end of file diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..dc6296c --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,26 @@ +import z, { ZodObject } from "zod"; + +export function createSchema(appData: ZodObject) { + return { + identification: { + request: z.object({ + app_identifier: z.string(), + app_session_id: z.number(), + app_public_key: z.string(), + user_id: z.number(), + }), + response: z.object({ + challenge: z.base64(), + public_key: z.base64(), + }), + }, + challenge_response: { + request: z.object({ + challenge: z.base64(), + }), + response: z.object({ + app_data: appData, + }), + }, + }; +} diff --git a/src/user.ts b/src/user.ts new file mode 100644 index 0000000..c4943aa --- /dev/null +++ b/src/user.ts @@ -0,0 +1,34 @@ +export type User = { + status: string; + username: string; + public_key: string; + user_id: number; + iota_id: number; + sub_level: number; + sub_end: number; + display: string; + status_message: string; + about: string; +}; + +const userCacheMap = new Map(); + +export async function get(userId: number): Promise { + try { + const cachedUser = userCacheMap.get(userId); + if (cachedUser) { + return cachedUser; + } + + const fetchedUser = await fetch( + "https://omega.tensamin.net/api/get/user/" + userId, + ).then((res) => res.json()); + if (fetchedUser) { + userCacheMap.set(userId, fetchedUser); + } + return fetchedUser; + } catch (error) { + console.error("Error fetching user data for userId:", userId, error); + return undefined; + } +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..cd25925 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": false, + "emitDeclarationOnly": false, + "outDir": "./dist", + "rootDir": "./src", + "noUnusedLocals": false, + "noUnusedParameters": false, + "allowImportingTsExtensions": false, + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "sourceMap": false + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0cb920c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ES2022", "DOM"], + "target": "ESNext", + "module": "esnext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + + // Node.js types + "types": ["node"] + } +}