From 9cf59c409e1c8bab77bb8cbf7283194a8e616877 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 16 Feb 2026 15:19:29 -0800 Subject: [PATCH] Initial sign endpoint working --- package-lock.json | 177 ++++++++- package.json | 2 + server/auth/actions.ts | 3 +- server/auth/canUserAccessSiteResource.ts | 45 +++ server/db/pg/schema/schema.ts | 4 +- server/db/sqlite/schema/schema.ts | 4 +- server/lib/billing/tierMatrix.ts | 6 +- server/lib/createUserAccountOrg.ts | 11 +- server/openApi.ts | 3 +- server/private/lib/sshCA.ts | 442 +++++++++++++++++++++++ server/private/routers/external.ts | 12 + server/private/routers/ssh/index.ts | 14 + server/private/routers/ssh/signSshKey.ts | 265 ++++++++++++++ server/routers/org/createOrg.ts | 11 +- 14 files changed, 985 insertions(+), 14 deletions(-) create mode 100644 server/auth/canUserAccessSiteResource.ts create mode 100644 server/private/lib/sshCA.ts create mode 100644 server/private/routers/ssh/index.ts create mode 100644 server/private/routers/ssh/signSshKey.ts diff --git a/package-lock.json b/package-lock.json index cb1c84b9..dabdcd33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,6 +89,7 @@ "reodotdev": "1.0.0", "resend": "6.9.2", "semver": "7.7.4", + "sshpk": "^1.18.0", "stripe": "20.3.1", "swagger-ui-express": "5.0.1", "tailwind-merge": "3.4.0", @@ -129,6 +130,7 @@ "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@types/semver": "7.7.1", + "@types/sshpk": "^1.17.4", "@types/swagger-ui-express": "4.1.8", "@types/topojson-client": "3.1.5", "@types/ws": "8.18.1", @@ -1084,6 +1086,7 @@ "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -2815,6 +2818,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2837,6 +2841,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2859,6 +2864,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2875,6 +2881,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2891,6 +2898,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2907,6 +2915,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2923,6 +2932,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2939,6 +2949,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2955,6 +2966,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2971,6 +2983,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2987,6 +3000,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3003,6 +3017,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3025,6 +3040,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3047,6 +3063,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3069,6 +3086,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3091,6 +3109,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3113,6 +3132,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3135,6 +3155,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3157,6 +3178,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -3176,6 +3198,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3195,6 +3218,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3214,6 +3238,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3495,6 +3520,7 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -7924,6 +7950,7 @@ "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz", "integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==", "license": "MIT", + "peer": true, "engines": { "node": ">=20.0.0" }, @@ -9342,6 +9369,7 @@ "version": "5.90.21", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "peer": true, "dependencies": { "@tanstack/query-core": "5.90.20" }, @@ -9441,12 +9469,23 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@types/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -9787,6 +9826,7 @@ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -9881,6 +9921,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", "devOptional": true, + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -9908,6 +9949,7 @@ "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -9933,6 +9975,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -9943,6 +9986,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -9975,6 +10019,17 @@ "@types/node": "*" } }, + "node_modules/@types/sshpk": { + "version": "1.17.4", + "resolved": "https://registry.npmjs.org/@types/sshpk/-/sshpk-1.17.4.tgz", + "integrity": "sha512-5gI/7eJn6wmkuIuFY8JZJ1g5b30H9K5U5vKrvOuYu+hoZLb2xcVEgxhYZ2Vhbs0w/ACyzyfkJq0hQtBfSCugjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/asn1": "*", + "@types/node": "*" + } + }, "node_modules/@types/swagger-ui-express": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", @@ -10018,8 +10073,7 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@types/ws": { "version": "8.18.1", @@ -10090,6 +10144,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz", "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -10579,6 +10634,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10919,6 +10975,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/asn1js": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz", @@ -10933,6 +10998,15 @@ "node": ">=12.0.0" } }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -11025,6 +11099,7 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -11076,12 +11151,22 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/better-sqlite3": { "version": "11.9.1", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.9.1.tgz", "integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -11208,6 +11293,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -12161,6 +12247,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -12252,6 +12339,18 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -12589,7 +12688,6 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -13275,6 +13373,16 @@ "node": ">= 0.4" } }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -13693,6 +13801,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -13791,6 +13900,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -13976,6 +14086,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -14295,6 +14406,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -14935,6 +15047,15 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -15954,6 +16075,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT" + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -16789,7 +16916,6 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", - "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -16800,7 +16926,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -16888,6 +17013,7 @@ "version": "15.5.12", "resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz", "integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==", + "peer": true, "dependencies": { "@next/env": "15.5.12", "@swc/helpers": "0.5.15", @@ -17822,6 +17948,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", @@ -18316,6 +18443,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -18345,6 +18473,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -19161,6 +19290,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -20187,6 +20317,31 @@ "node": ">= 10.x" } }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "license": "MIT", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -20605,7 +20760,8 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -20946,6 +21102,12 @@ "url": "https://github.com/sponsors/Wombosvideo" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -21073,6 +21235,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -21499,6 +21662,7 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", @@ -21705,6 +21869,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index c8af24ba..f7ac6fbc 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "reodotdev": "1.0.0", "resend": "6.9.2", "semver": "7.7.4", + "sshpk": "^1.18.0", "stripe": "20.3.1", "swagger-ui-express": "5.0.1", "tailwind-merge": "3.4.0", @@ -152,6 +153,7 @@ "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@types/semver": "7.7.1", + "@types/sshpk": "^1.17.4", "@types/swagger-ui-express": "4.1.8", "@types/topojson-client": "3.1.5", "@types/ws": "8.18.1", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 094437f4..3f5a145b 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -131,7 +131,8 @@ export enum ActionsEnum { viewLogs = "viewLogs", exportLogs = "exportLogs", listApprovals = "listApprovals", - updateApprovals = "updateApprovals" + updateApprovals = "updateApprovals", + signSshKey = "signSshKey" } export async function checkUserActionPermission( diff --git a/server/auth/canUserAccessSiteResource.ts b/server/auth/canUserAccessSiteResource.ts new file mode 100644 index 00000000..959b0eff --- /dev/null +++ b/server/auth/canUserAccessSiteResource.ts @@ -0,0 +1,45 @@ +import { db } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import { roleSiteResources, userSiteResources } from "@server/db"; + +export async function canUserAccessSiteResource({ + userId, + resourceId, + roleId +}: { + userId: string; + resourceId: number; + roleId: number; +}): Promise { + const roleResourceAccess = await db + .select() + .from(roleSiteResources) + .where( + and( + eq(roleSiteResources.siteResourceId, resourceId), + eq(roleSiteResources.roleId, roleId) + ) + ) + .limit(1); + + if (roleResourceAccess.length > 0) { + return true; + } + + const userResourceAccess = await db + .select() + .from(userSiteResources) + .where( + and( + eq(userSiteResources.userId, userId), + eq(userSiteResources.siteResourceId, resourceId) + ) + ) + .limit(1); + + if (userResourceAccess.length > 0) { + return true; + } + + return false; +} diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 6afd463e..4188d894 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -53,7 +53,9 @@ export const orgs = pgTable("orgs", { .default(0), settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() - .default(0) + .default(0), + sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) + sshCaPublicKey: text("sshCaPublicKey") // SSH CA public key (OpenSSH format) }); export const orgDomains = pgTable("orgDomains", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 7335f666..6d60ec68 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -45,7 +45,9 @@ export const orgs = sqliteTable("orgs", { .default(0), settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() - .default(0) + .default(0), + sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) + sshCaPublicKey: text("sshCaPublicKey") // SSH CA public key (OpenSSH format) }); export const userDomains = sqliteTable("userDomains", { diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index d1fe362a..20f8001d 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -14,7 +14,8 @@ export enum TierFeature { TwoFactorEnforcement = "twoFactorEnforcement", // handle downgrade by setting to optional SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration - AutoProvisioning = "autoProvisioning" // handle downgrade by disabling auto provisioning + AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning + SshPam = "sshPam" } export const tierMatrix: Record = { @@ -46,5 +47,6 @@ export const tierMatrix: Record = { "tier3", "enterprise" ], - [TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"] + [TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"], + [TierFeature.SshPam]: ["enterprise"] }; diff --git a/server/lib/createUserAccountOrg.ts b/server/lib/createUserAccountOrg.ts index 53f2ea3d..a40407d1 100644 --- a/server/lib/createUserAccountOrg.ts +++ b/server/lib/createUserAccountOrg.ts @@ -19,6 +19,8 @@ import { FeatureId, limitsService, sandboxLimitSet } from "@server/lib/billing"; import { createCustomer } from "#dynamic/lib/billing"; import { usageService } from "@server/lib/billing/usageService"; import config from "@server/lib/config"; +import { generateCA } from "@server/private/lib/sshCA"; +import { encrypt } from "@server/lib/crypto"; export async function createUserAccountOrg( userId: string, @@ -79,6 +81,11 @@ export async function createUserAccountOrg( const utilitySubnet = config.getRawConfig().orgs.utility_subnet_group; + // Generate SSH CA keys for the org + const ca = generateCA(`${orgId}-ca`); + const encryptionKey = config.getRawConfig().server.secret!; + const encryptedCaPrivateKey = encrypt(ca.privateKeyPem, encryptionKey); + const newOrg = await trx .insert(orgs) .values({ @@ -87,7 +94,9 @@ export async function createUserAccountOrg( // subnet subnet: "100.90.128.0/24", // TODO: this should not be hardcoded - or can it be the same in all orgs? utilitySubnet: utilitySubnet, - createdAt: new Date().toISOString() + createdAt: new Date().toISOString(), + sshCaPrivateKey: encryptedCaPrivateKey, + sshCaPublicKey: ca.publicKeyOpenSSH }) .returning(); diff --git a/server/openApi.ts b/server/openApi.ts index 68b05a30..88626568 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -16,5 +16,6 @@ export enum OpenAPITags { Client = "Client", ApiKey = "API Key", Domain = "Domain", - Blueprint = "Blueprint" + Blueprint = "Blueprint", + Ssh = "SSH" } diff --git a/server/private/lib/sshCA.ts b/server/private/lib/sshCA.ts new file mode 100644 index 00000000..145dac61 --- /dev/null +++ b/server/private/lib/sshCA.ts @@ -0,0 +1,442 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import * as crypto from "crypto"; + +/** + * SSH CA "Server" - Pure TypeScript Implementation + * + * This module provides basic SSH Certificate Authority functionality using + * only Node.js built-in crypto module. No external dependencies or subprocesses. + * + * Usage: + * 1. generateCA() - Creates a new CA key pair, returns CA info including the + * TrustedUserCAKeys line to add to servers + * 2. signPublicKey() - Signs a user's public key with the CA, returns a certificate + */ + +// ============================================================================ +// SSH Wire Format Helpers +// ============================================================================ + +/** + * Encode a string in SSH wire format (4-byte length prefix + data) + */ +function encodeString(data: Buffer | string): Buffer { + const buf = typeof data === "string" ? Buffer.from(data, "utf8") : data; + const len = Buffer.alloc(4); + len.writeUInt32BE(buf.length, 0); + return Buffer.concat([len, buf]); +} + +/** + * Encode a uint32 in SSH wire format (big-endian) + */ +function encodeUInt32(value: number): Buffer { + const buf = Buffer.alloc(4); + buf.writeUInt32BE(value, 0); + return buf; +} + +/** + * Encode a uint64 in SSH wire format (big-endian) + */ +function encodeUInt64(value: bigint): Buffer { + const buf = Buffer.alloc(8); + buf.writeBigUInt64BE(value, 0); + return buf; +} + +/** + * Decode a string from SSH wire format at the given offset + * Returns the string buffer and the new offset + */ +function decodeString(data: Buffer, offset: number): { value: Buffer; newOffset: number } { + const len = data.readUInt32BE(offset); + const value = data.subarray(offset + 4, offset + 4 + len); + return { value, newOffset: offset + 4 + len }; +} + +// ============================================================================ +// SSH Public Key Parsing/Encoding +// ============================================================================ + +/** + * Parse an OpenSSH public key line (e.g., "ssh-ed25519 AAAA... comment") + */ +function parseOpenSSHPublicKey(pubKeyLine: string): { + keyType: string; + keyData: Buffer; + comment: string; +} { + const parts = pubKeyLine.trim().split(/\s+/); + if (parts.length < 2) { + throw new Error("Invalid public key format"); + } + + const keyType = parts[0]; + const keyData = Buffer.from(parts[1], "base64"); + const comment = parts.slice(2).join(" ") || ""; + + // Verify the key type in the blob matches + const { value: blobKeyType } = decodeString(keyData, 0); + if (blobKeyType.toString("utf8") !== keyType) { + throw new Error(`Key type mismatch: ${blobKeyType.toString("utf8")} vs ${keyType}`); + } + + return { keyType, keyData, comment }; +} + +/** + * Encode an Ed25519 public key in OpenSSH format + */ +function encodeEd25519PublicKey(publicKey: Buffer): Buffer { + return Buffer.concat([ + encodeString("ssh-ed25519"), + encodeString(publicKey) + ]); +} + +/** + * Format a public key blob as an OpenSSH public key line + */ +function formatOpenSSHPublicKey(keyBlob: Buffer, comment: string = ""): string { + const { value: keyType } = decodeString(keyBlob, 0); + const base64 = keyBlob.toString("base64"); + return `${keyType.toString("utf8")} ${base64}${comment ? " " + comment : ""}`; +} + +// ============================================================================ +// SSH Certificate Building +// ============================================================================ + +interface CertificateOptions { + /** Serial number for the certificate */ + serial?: bigint; + /** Certificate type: 1 = user, 2 = host */ + certType?: number; + /** Key ID (usually username or identifier) */ + keyId: string; + /** List of valid principals (usernames the cert is valid for) */ + validPrincipals: string[]; + /** Valid after timestamp (seconds since epoch) */ + validAfter?: bigint; + /** Valid before timestamp (seconds since epoch) */ + validBefore?: bigint; + /** Critical options (usually empty for user certs) */ + criticalOptions?: Map; + /** Extensions to enable */ + extensions?: string[]; +} + +/** + * Build the extensions section of the certificate + */ +function buildExtensions(extensions: string[]): Buffer { + // Extensions are a series of name-value pairs, sorted by name + // For boolean extensions, the value is empty + const sortedExtensions = [...extensions].sort(); + + const parts: Buffer[] = []; + for (const ext of sortedExtensions) { + parts.push(encodeString(ext)); + parts.push(encodeString("")); // Empty value for boolean extensions + } + + return encodeString(Buffer.concat(parts)); +} + +/** + * Build the critical options section + */ +function buildCriticalOptions(options: Map): Buffer { + const sortedKeys = [...options.keys()].sort(); + + const parts: Buffer[] = []; + for (const key of sortedKeys) { + parts.push(encodeString(key)); + parts.push(encodeString(encodeString(options.get(key)!))); + } + + return encodeString(Buffer.concat(parts)); +} + +/** + * Build the valid principals section + */ +function buildPrincipals(principals: string[]): Buffer { + const parts: Buffer[] = []; + for (const principal of principals) { + parts.push(encodeString(principal)); + } + return encodeString(Buffer.concat(parts)); +} + +/** + * Extract the raw Ed25519 public key from an OpenSSH public key blob + */ +function extractEd25519PublicKey(keyBlob: Buffer): Buffer { + const { newOffset } = decodeString(keyBlob, 0); // Skip key type + const { value: publicKey } = decodeString(keyBlob, newOffset); + return publicKey; +} + +// ============================================================================ +// CA Interface +// ============================================================================ + +export interface CAKeyPair { + /** CA private key in PEM format (keep this secret!) */ + privateKeyPem: string; + /** CA public key in PEM format */ + publicKeyPem: string; + /** CA public key in OpenSSH format (for TrustedUserCAKeys) */ + publicKeyOpenSSH: string; + /** Raw CA public key bytes (Ed25519) */ + publicKeyRaw: Buffer; +} + +export interface SignedCertificate { + /** The certificate in OpenSSH format (save as id_ed25519-cert.pub or similar) */ + certificate: string; + /** The certificate type string */ + certType: string; + /** Serial number */ + serial: bigint; + /** Key ID */ + keyId: string; + /** Valid principals */ + validPrincipals: string[]; + /** Valid from timestamp */ + validAfter: Date; + /** Valid until timestamp */ + validBefore: Date; +} + +// ============================================================================ +// Main Functions +// ============================================================================ + +/** + * Generate a new SSH Certificate Authority key pair. + * + * Returns the CA keys and the line to add to /etc/ssh/sshd_config: + * TrustedUserCAKeys /etc/ssh/ca.pub + * + * Then save the publicKeyOpenSSH to /etc/ssh/ca.pub on the server. + * + * @param comment - Optional comment for the CA public key + * @returns CA key pair and configuration info + */ +export function generateCA(comment: string = "ssh-ca"): CAKeyPair { + // Generate Ed25519 key pair + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519", { + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" } + }); + + // Get raw public key bytes + const pubKeyObj = crypto.createPublicKey(publicKey); + const rawPubKey = pubKeyObj.export({ type: "spki", format: "der" }); + // Ed25519 SPKI format: 12 byte header + 32 byte key + const ed25519PubKey = rawPubKey.subarray(rawPubKey.length - 32); + + // Create OpenSSH format public key + const pubKeyBlob = encodeEd25519PublicKey(ed25519PubKey); + const publicKeyOpenSSH = formatOpenSSHPublicKey(pubKeyBlob, comment); + + return { + privateKeyPem: privateKey, + publicKeyPem: publicKey, + publicKeyOpenSSH, + publicKeyRaw: ed25519PubKey + }; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Get and decrypt the SSH CA keys for an organization. + * + * @param orgId - Organization ID + * @param decryptionKey - Key to decrypt the CA private key (typically server.secret from config) + * @returns CA key pair or null if not found + */ +export async function getOrgCAKeys( + orgId: string, + decryptionKey: string +): Promise { + const { db, orgs } = await import("@server/db"); + const { eq } = await import("drizzle-orm"); + const { decrypt } = await import("@server/lib/crypto"); + + const [org] = await db + .select({ + sshCaPrivateKey: orgs.sshCaPrivateKey, + sshCaPublicKey: orgs.sshCaPublicKey + }) + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org || !org.sshCaPrivateKey || !org.sshCaPublicKey) { + return null; + } + + const privateKeyPem = decrypt(org.sshCaPrivateKey, decryptionKey); + + // Extract raw public key from the OpenSSH format + const { keyData } = parseOpenSSHPublicKey(org.sshCaPublicKey); + const { newOffset } = decodeString(keyData, 0); // Skip key type + const { value: publicKeyRaw } = decodeString(keyData, newOffset); + + // Get PEM format of public key + const pubKeyObj = crypto.createPublicKey({ + key: privateKeyPem, + format: "pem" + }); + const publicKeyPem = pubKeyObj.export({ type: "spki", format: "pem" }) as string; + + return { + privateKeyPem, + publicKeyPem, + publicKeyOpenSSH: org.sshCaPublicKey, + publicKeyRaw + }; +} + +/** + * Sign a user's SSH public key with the CA, producing a certificate. + * + * The resulting certificate should be saved alongside the user's private key + * with a -cert.pub suffix. For example: + * - Private key: ~/.ssh/id_ed25519 + * - Certificate: ~/.ssh/id_ed25519-cert.pub + * + * @param caPrivateKeyPem - CA private key in PEM format + * @param userPublicKeyLine - User's public key in OpenSSH format + * @param options - Certificate options (principals, validity, etc.) + * @returns Signed certificate + */ +export function signPublicKey( + caPrivateKeyPem: string, + userPublicKeyLine: string, + options: CertificateOptions +): SignedCertificate { + // Parse the user's public key + const { keyType, keyData } = parseOpenSSHPublicKey(userPublicKeyLine); + + // Determine certificate type string + let certTypeString: string; + if (keyType === "ssh-ed25519") { + certTypeString = "ssh-ed25519-cert-v01@openssh.com"; + } else if (keyType === "ssh-rsa") { + certTypeString = "ssh-rsa-cert-v01@openssh.com"; + } else if (keyType === "ecdsa-sha2-nistp256") { + certTypeString = "ecdsa-sha2-nistp256-cert-v01@openssh.com"; + } else if (keyType === "ecdsa-sha2-nistp384") { + certTypeString = "ecdsa-sha2-nistp384-cert-v01@openssh.com"; + } else if (keyType === "ecdsa-sha2-nistp521") { + certTypeString = "ecdsa-sha2-nistp521-cert-v01@openssh.com"; + } else { + throw new Error(`Unsupported key type: ${keyType}`); + } + + // Get CA public key from private key + const caPrivKey = crypto.createPrivateKey(caPrivateKeyPem); + const caPubKey = crypto.createPublicKey(caPrivKey); + const caRawPubKey = caPubKey.export({ type: "spki", format: "der" }); + const caEd25519PubKey = caRawPubKey.subarray(caRawPubKey.length - 32); + const caPubKeyBlob = encodeEd25519PublicKey(caEd25519PubKey); + + // Set defaults + const serial = options.serial ?? BigInt(Date.now()); + const certType = options.certType ?? 1; // 1 = user cert + const now = BigInt(Math.floor(Date.now() / 1000)); + const validAfter = options.validAfter ?? (now - 60n); // 1 minute ago + const validBefore = options.validBefore ?? (now + 86400n * 365n); // 1 year from now + + // Default extensions for user certificates + const defaultExtensions = [ + "permit-X11-forwarding", + "permit-agent-forwarding", + "permit-port-forwarding", + "permit-pty", + "permit-user-rc" + ]; + const extensions = options.extensions ?? defaultExtensions; + const criticalOptions = options.criticalOptions ?? new Map(); + + // Generate nonce (random bytes) + const nonce = crypto.randomBytes(32); + + // Extract the public key portion from the user's key blob + // For Ed25519: skip the key type string, get the public key (already encoded) + let userKeyPortion: Buffer; + if (keyType === "ssh-ed25519") { + // Skip the key type string, take the rest (which is encodeString(32-byte-key)) + const { newOffset } = decodeString(keyData, 0); + userKeyPortion = keyData.subarray(newOffset); + } else { + // For other key types, extract everything after the key type + const { newOffset } = decodeString(keyData, 0); + userKeyPortion = keyData.subarray(newOffset); + } + + // Build the certificate body (to be signed) + const certBody = Buffer.concat([ + encodeString(certTypeString), + encodeString(nonce), + userKeyPortion, + encodeUInt64(serial), + encodeUInt32(certType), + encodeString(options.keyId), + buildPrincipals(options.validPrincipals), + encodeUInt64(validAfter), + encodeUInt64(validBefore), + buildCriticalOptions(criticalOptions), + buildExtensions(extensions), + encodeString(""), // reserved + encodeString(caPubKeyBlob) // signature key (CA public key) + ]); + + // Sign the certificate body + const signature = crypto.sign(null, certBody, caPrivKey); + + // Build the full signature blob (algorithm + signature) + const signatureBlob = Buffer.concat([ + encodeString("ssh-ed25519"), + encodeString(signature) + ]); + + // Build complete certificate + const certificate = Buffer.concat([ + certBody, + encodeString(signatureBlob) + ]); + + // Format as OpenSSH certificate line + const certLine = `${certTypeString} ${certificate.toString("base64")} ${options.keyId}`; + + return { + certificate: certLine, + certType: certTypeString, + serial, + keyId: options.keyId, + validPrincipals: options.validPrincipals, + validAfter: new Date(Number(validAfter) * 1000), + validBefore: new Date(Number(validBefore) * 1000) + }; +} diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index dae10a95..17132c44 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -25,6 +25,7 @@ import * as logs from "#private/routers/auditLogs"; import * as misc from "#private/routers/misc"; import * as reKey from "#private/routers/re-key"; import * as approval from "#private/routers/approvals"; +import * as ssh from "#private/routers/ssh"; import { verifyOrgAccess, @@ -506,3 +507,14 @@ authenticated.put( verifyUserHasAction(ActionsEnum.reGenerateSecret), reKey.reGenerateExitNodeSecret ); + +authenticated.post( + "/org/:orgId/ssh/sign-key", + verifyValidLicense, + verifyValidSubscription(tierMatrix.sshPam), + verifyOrgAccess, + verifyLimits, + // verifyUserHasAction(ActionsEnum.signSshKey), + logActionAudit(ActionsEnum.signSshKey), + ssh.signSshKey +); diff --git a/server/private/routers/ssh/index.ts b/server/private/routers/ssh/index.ts new file mode 100644 index 00000000..a98405ba --- /dev/null +++ b/server/private/routers/ssh/index.ts @@ -0,0 +1,14 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./signSshKey"; \ No newline at end of file diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts new file mode 100644 index 00000000..62a830cb --- /dev/null +++ b/server/private/routers/ssh/signSshKey.ts @@ -0,0 +1,265 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, orgs, siteResources } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { eq, or } from "drizzle-orm"; +import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource"; +import { signPublicKey, getOrgCAKeys } from "#private/lib/sshCA"; +import config from "@server/lib/config"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const bodySchema = z + .strictObject({ + publicKey: z.string().nonempty(), + resourceId: z.number().int().positive().optional(), + niceId: z.string().nonempty().optional(), + alias: z.string().nonempty().optional() + }) + .refine( + (data) => { + const fields = [data.resourceId, data.niceId, data.alias]; + const definedFields = fields.filter((field) => field !== undefined); + return definedFields.length === 1; + }, + { + message: + "Exactly one of resourceId, niceId, or alias must be provided" + } + ); + +export type SignSshKeyResponse = { + certificate: string; + sshUsername: string; + sshHost: string; + resourceId: number; + keyId: string; + validPrincipals: string[]; + validAfter: string; + validBefore: string; + expiresIn: number; +}; + +// registry.registerPath({ +// method: "post", +// path: "/org/{orgId}/ssh/sign-key", +// description: "Sign an SSH public key for access to a resource.", +// tags: [OpenAPITags.Org, OpenAPITags.Ssh], +// request: { +// params: paramsSchema, +// body: { +// content: { +// "application/json": { +// schema: bodySchema +// } +// } +// } +// }, +// responses: {} +// }); + +export async function signSshKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { publicKey, resourceId, niceId, alias } = parsedBody.data; + const userId = req.user?.userId; + const roleId = req.userOrgRoleId!; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + // Get and decrypt the org's CA keys + const caKeys = await getOrgCAKeys( + orgId, + config.getRawConfig().server.secret! + ); + + if (!caKeys) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "SSH CA not configured for this organization" + ) + ); + } + + // Verify the resource exists and belongs to the org + // Build the where clause dynamically based on which field is provided + let whereClause; + if (resourceId !== undefined) { + whereClause = eq(siteResources.siteResourceId, resourceId); + } else if (niceId !== undefined) { + whereClause = eq(siteResources.niceId, niceId); + } else if (alias !== undefined) { + whereClause = eq(siteResources.alias, alias); + } else { + // This should never happen due to the schema validation, but TypeScript doesn't know that + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One of resourceId, niceId, or alias must be provided" + ) + ); + } + + const [resource] = await db + .select() + .from(siteResources) + .where(whereClause) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource not found` + ) + ); + } + + if (resource.orgId !== orgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Resource does not belong to the specified organization" + ) + ); + } + + // Check if the user has access to the resource + const hasAccess = await canUserAccessSiteResource({ + userId: userId, + resourceId: resource.siteResourceId, + roleId: roleId + }); + + if (!hasAccess) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this resource" + ) + ); + } + + let usernameToUse; + if (req.user?.email) { + // Extract username from email (first part before @) + usernameToUse = req.user?.email.split("@")[0]; + if (!usernameToUse) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Unable to extract username from email" + ) + ); + } + } else if (req.user?.username) { + usernameToUse = req.user.username; + // We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates + usernameToUse = usernameToUse.replace(/[^a-zA-Z0-9_-]/g, ""); + if (!usernameToUse) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Username is not valid for SSH certificate" + ) + ); + } + } else { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User does not have a valid email or username for SSH certificate" + ) + ); + } + + // Sign the public key + const now = BigInt(Math.floor(Date.now() / 1000)); + // only valid for 5 minutes + const validFor = 300n; + + const cert = signPublicKey(caKeys.privateKeyPem, publicKey, { + keyId: `${usernameToUse}@${orgId}`, + validPrincipals: [usernameToUse, resource.niceId], + validAfter: now - 60n, // Start 1 min ago for clock skew + validBefore: now + validFor + }); + + const expiresIn = Number(validFor); // seconds + + return response(res, { + data: { + certificate: cert.certificate, + sshUsername: usernameToUse, + sshHost: resource.niceId, + resourceId: resource.siteResourceId, + keyId: cert.keyId, + validPrincipals: cert.validPrincipals, + validAfter: cert.validAfter.toISOString(), + validBefore: cert.validBefore.toISOString(), + expiresIn + }, + success: true, + error: false, + message: "SSH key signed successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error("Error signing SSH key:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred while signing the SSH key" + ) + ); + } +} diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index 29468ca1..b8e2d625 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -28,6 +28,8 @@ import { FeatureId } from "@server/lib/billing"; import { build } from "@server/build"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { doCidrsOverlap } from "@server/lib/ip"; +import { generateCA } from "@server/private/lib/sshCA"; +import { encrypt } from "@server/lib/crypto"; const createOrgSchema = z.strictObject({ orgId: z.string(), @@ -143,6 +145,11 @@ export async function createOrg( .from(domains) .where(eq(domains.configManaged, true)); + // Generate SSH CA keys for the org + const ca = generateCA(`${orgId}-ca`); + const encryptionKey = config.getRawConfig().server.secret!; + const encryptedCaPrivateKey = encrypt(ca.privateKeyPem, encryptionKey); + const newOrg = await trx .insert(orgs) .values({ @@ -150,7 +157,9 @@ export async function createOrg( name, subnet, utilitySubnet, - createdAt: new Date().toISOString() + createdAt: new Date().toISOString(), + sshCaPrivateKey: encryptedCaPrivateKey, + sshCaPublicKey: ca.publicKeyOpenSSH }) .returning();