feat: implement SSH protocol components including key exchange, packet handling, and transport
- Add KEXInitBuilder and parsing functions for SSH key exchange initialization. - Implement key derivation for session keys using shared secrets and exchange hashes. - Create SSHPacketParser and SSHPacketBuilder for handling SSH packets. - Establish SSHTransport for managing version exchange and connection setup. - Introduce utility functions for data manipulation and encoding. - Define types for SSH packets, key exchange messages, and session configurations. - Set up worker environment with session management and request handling. - Configure TypeScript and Vite for frontend and worker builds. - Add tests and coverage configuration for SSH components. - Initialize Wrangler configuration for deployment.
This commit is contained in:
1 parent
17a6027bf4
commit
623d270eec
33 files changed
+9997
-1
No files matched your search
@@ -0,0 +1,4 @@
|
||||
# Copy to .dev.vars for local development. Do not commit real credentials.
|
||||
ACCESS_TOKEN=replace-with-a-long-random-value
|
||||
ALLOW_ANONYMOUS=false
|
||||
CONNECT_TIMEOUT_MS=10000
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.wrangler/
|
||||
coverage/
|
||||
tests/
|
||||
tests-edge/
|
||||
*.log
|
||||
.dev.vars
|
||||
.env*
|
||||
!.env.example
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1 +1,492 @@
|
||||
# CF-Workers-WebSSH
|
||||
# CF-Workers-WebSSH
|
||||
|
||||
一个运行在 Cloudflare Workers 上的原生 WebSSH 终端。浏览器通过 HTTPS/WebSocket 连接 Worker,Worker 使用 Cloudflare TCP Sockets 直接连接公网 SSH 服务器,并在边缘运行时内完成 SSH 2.0 握手、主机密钥校验、用户认证和交互式 PTY 会话。
|
||||
|
||||
项目不依赖额外的 WebSSH 后端、容器或常驻服务器;前端、会话网关、SSH 客户端实现和静态资源均由同一个 Worker 部署提供。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 这是一个 SSH 访问网关。部署者能够控制 Worker 代码和运行时配置,请只使用自己信任的部署。生产环境务必设置 `ACCESS_TOKEN` 或使用等效的 Cloudflare Access 保护,不要直接开放匿名访问。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- Cloudflare Workers 原生部署,使用 Durable Objects 隔离每个 SSH 会话。
|
||||
- 基于 xterm.js 的响应式终端,支持桌面端和移动端、自动缩放、全屏和会话日志。
|
||||
- 支持密码认证,以及 Ed25519、RSA、ECDSA 的未加密 OpenSSH 私钥认证。
|
||||
- 首次连接时暂停认证并显示主机 SHA-256 指纹,确认后才会发送 SSH 凭据。
|
||||
- 可在浏览器本地保存连接配置和主机指纹,但不保存密码、私钥或网关令牌。
|
||||
- 支持 UTF-8、GB18030、Big5 显示编码、初始命令和无凭据分享链接。
|
||||
- 提供一次性会话票据、同源检查、HTTPS 强制、安全响应头和公网目标校验。
|
||||
- 运行时无 SSH 第三方依赖,SSH 数据包、密钥交换、加密、认证和通道逻辑均使用 TypeScript 与 Web Crypto 实现。
|
||||
|
||||
## 工作原理
|
||||
|
||||
```text
|
||||
浏览器(xterm.js)
|
||||
│ HTTPS:申请一次性会话票据
|
||||
│ WSS:终端输入、输出和控制消息
|
||||
▼
|
||||
Cloudflare Worker
|
||||
│ 同源检查、ACCESS_TOKEN 校验、静态资源
|
||||
▼
|
||||
每会话 Durable Object
|
||||
│ 消耗一次性票据、校验目标地址、运行 SSH 2.0 客户端
|
||||
│ Cloudflare TCP Socket
|
||||
▼
|
||||
公网 SSH 服务器
|
||||
```
|
||||
|
||||
一次连接的主要流程如下:
|
||||
|
||||
1. 浏览器向 `POST /api/session` 提交网关访问令牌。
|
||||
2. Worker 创建独立 Durable Object,并签发一个有效期 60 秒、绑定客户端地址且只能使用一次的票据。
|
||||
3. 浏览器携带票据连接 `GET /api/ssh` WebSocket;Durable Object 消耗票据。
|
||||
4. Worker 解析目标域名,通过 Cloudflare DNS over HTTPS 获取 A/AAAA 记录,拒绝私有、保留或本地地址。
|
||||
5. Durable Object 只连接已经通过校验的 IP,避免 DNS 重绑定,然后在该 TCP 连接上执行 SSH 协议。
|
||||
6. 主机密钥签名验证成功后,浏览器确认或比对固定指纹;只有通过后才发送用户认证数据。
|
||||
7. 认证成功后建立 PTY 和交互式 Shell,浏览器与 SSH 通道之间开始转发终端数据。
|
||||
|
||||
## 支持范围
|
||||
|
||||
| 类别 | 当前支持 |
|
||||
| --- | --- |
|
||||
| SSH 协议 | SSH 2.0 交互式 Shell、PTY、窗口尺寸同步、Keepalive |
|
||||
| 用户认证 | Password、OpenSSH Ed25519、RSA、ECDSA P-256/P-384/P-521 私钥 |
|
||||
| 密钥交换 | `curve25519-sha256`、`ecdh-sha2-nistp256` |
|
||||
| 主机密钥 | Ed25519、ECDSA P-256/P-384/P-521、RSA SHA-2 |
|
||||
| 加密算法 | AES-128/256-GCM、AES-128/192/256-CTR |
|
||||
| MAC | HMAC-SHA2-256、HMAC-SHA2-512(AES-GCM 不使用独立 MAC) |
|
||||
| 压缩 | 仅 `none` |
|
||||
| 终端编码 | UTF-8、GB18030、Big5(取决于浏览器 `TextDecoder` 支持) |
|
||||
|
||||
当前限制:
|
||||
|
||||
- 只能连接公网 IP 或解析结果全部为公网地址的域名;内网、回环、链路本地、保留地址和 `.internal` 目标会被拒绝。
|
||||
- Cloudflare Workers 不允许连接出站 TCP 25 端口,本项目也会提前拒绝该端口。
|
||||
- 不支持加密私钥、PEM/PKCS#8 私钥文件;私钥必须是 `BEGIN OPENSSH PRIVATE KEY` 格式,且前端限制为 64 KiB。
|
||||
- 不支持 SSH Agent、键盘交互认证、多因素认证、证书认证、SFTP/SCP、端口转发和 ProxyJump。
|
||||
- 不支持 SSH 压缩和会话内重新密钥交换(rekey)。服务端主动 rekey 时当前会话会断开,需要重新连接。
|
||||
- 每个浏览器页面同时维护一个活动 SSH 会话。Worker 更新或 Durable Object 重启时,活动会话需要重新连接。
|
||||
|
||||
## 安全说明
|
||||
|
||||
### 已实现的保护
|
||||
|
||||
- 默认关闭:未配置 `ACCESS_TOKEN` 且 `ALLOW_ANONYMOUS` 不为 `true` 时,会话接口返回 `503`。
|
||||
- `ACCESS_TOKEN` 使用恒定时间比较;一次性票据使用随机密钥和 HMAC-SHA256 签名。
|
||||
- 会话票据绑定请求端 IP、60 秒过期并在第一次使用尝试时立即销毁,降低重放风险。
|
||||
- API 和 WebSocket 必须来自页面同源 `Origin`;Cloudflare 边缘上的 HTTP API 请求会被拒绝,页面请求会重定向至 HTTPS。
|
||||
- 域名解析后逐个检查公网地址,并直接连接已验证 IP,以限制 SSRF 与 DNS 重绑定。
|
||||
- SSH 主机密钥会验证交换签名并计算 `SHA256:` 指纹;没有固定指纹时,认证会暂停等待用户确认。
|
||||
- 密码、私钥和网关令牌不会写入 Local Storage 或 Durable Object 存储。连接表单在发起授权后立即清空,Worker 在生成认证请求后释放凭据引用。
|
||||
- 响应设置 CSP、HSTS、`X-Frame-Options`、`X-Content-Type-Options` 等安全头。
|
||||
|
||||
### 信任边界
|
||||
|
||||
浏览器到 Cloudflare 使用 TLS,Cloudflare Worker 到目标服务器使用 SSH 加密。Worker 是实际的 SSH 客户端,因此密码或私钥会在 Worker 会话内存中被处理;这不是浏览器到目标主机的端到端 SSH。请自行部署、保护 Cloudflare 账户,并为 WebSSH 使用权限最小化的独立账号或密钥。
|
||||
|
||||
第一次出现的主机指纹不能仅凭页面显示就判定可信。应通过服务器控制台、可信管理员或已有的安全通道核对。例如在 SSH 服务器上查看 Ed25519 主机密钥指纹:
|
||||
|
||||
```bash
|
||||
sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub -E sha256
|
||||
```
|
||||
|
||||
## 部署教程:Wrangler CLI(推荐)
|
||||
|
||||
### 1. 准备条件
|
||||
|
||||
部署前需要:
|
||||
|
||||
- 一个 Cloudflare 账户,并已启用 Workers 与 Durable Objects。
|
||||
- Node.js `22.12.0` 或更高版本,以及 npm。
|
||||
- 一台具有公网 IP 或公网 DNS 记录、可从 Cloudflare 网络访问的 SSH 服务器。
|
||||
- 目标 SSH 服务至少支持上表中的一组密钥交换、主机密钥和加密算法。
|
||||
|
||||
检查本机版本:
|
||||
|
||||
```bash
|
||||
node --version
|
||||
npm --version
|
||||
```
|
||||
|
||||
### 2. 获取代码并安装依赖
|
||||
|
||||
```bash
|
||||
git clone https://github.com/cmliu/CF-Workers-WebSSH.git
|
||||
cd CF-Workers-WebSSH
|
||||
npm ci
|
||||
```
|
||||
|
||||
如果部署自己的 Fork,请将 clone 地址替换为你的仓库地址。
|
||||
|
||||
### 3. 登录 Cloudflare
|
||||
|
||||
```bash
|
||||
npx wrangler login
|
||||
npx wrangler whoami
|
||||
```
|
||||
|
||||
`wrangler login` 会打开浏览器完成 OAuth 授权。无浏览器的 CI 环境应使用权限最小化的 Cloudflare API Token,并通过 CI Secret 提供 `CLOUDFLARE_API_TOKEN`,不要把 Token 写进仓库。
|
||||
|
||||
### 4. 检查 Worker 配置
|
||||
|
||||
默认配置位于 [`wrangler.toml`](wrangler.toml):
|
||||
|
||||
```toml
|
||||
name = "cf-workers-webssh"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2026-07-01"
|
||||
workers_dev = true
|
||||
|
||||
[assets]
|
||||
directory = "./dist"
|
||||
binding = "ASSETS"
|
||||
not_found_handling = "single-page-application"
|
||||
run_worker_first = true
|
||||
|
||||
[vars]
|
||||
ALLOW_ANONYMOUS = "false"
|
||||
CONNECT_TIMEOUT_MS = "10000"
|
||||
```
|
||||
|
||||
可以修改 `name`,它决定默认的 `*.workers.dev` 子域名。请保留 `SSH_SESSIONS` Durable Object binding 和 `v1` migration;首次部署会用它创建 `SSHSessionDO` SQLite 类。
|
||||
|
||||
### 5. 完整检查
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
```
|
||||
|
||||
该命令依次执行 Worker 与前端类型检查、Node 单元测试、Cloudflare 运行时测试、前端构建和 Wrangler dry-run。任何一步失败都应先修复,不建议跳过后直接部署。
|
||||
|
||||
也可以单独运行:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm test
|
||||
npm run test:edge
|
||||
npm run build:web
|
||||
```
|
||||
|
||||
### 6. 首次发布
|
||||
|
||||
```bash
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
`deploy` 会先把前端构建到 `dist/`,再由 Wrangler 上传 Worker、静态资源、Durable Object binding 和 migration。成功后终端会输出类似地址:
|
||||
|
||||
```text
|
||||
https://cf-workers-webssh.<your-subdomain>.workers.dev
|
||||
```
|
||||
|
||||
此时尚未设置 `ACCESS_TOKEN`,因此页面和健康接口可以访问,但默认 `ALLOW_ANONYMOUS = "false"` 会阻止创建 SSH 会话。这是安全的初始状态。
|
||||
|
||||
### 7. 设置网关访问令牌
|
||||
|
||||
先生成一个足够长的随机值。下面的命令适用于 Node.js:
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"
|
||||
```
|
||||
|
||||
然后以 Cloudflare Secret 方式保存,按提示粘贴刚生成的值:
|
||||
|
||||
```bash
|
||||
npx wrangler secret put ACCESS_TOKEN
|
||||
```
|
||||
|
||||
Wrangler 会为已部署的 Worker 创建并激活包含该 Secret 的新版本。Secret 不会出现在 `wrangler.toml` 或构建产物中。此后每次在页面连接时,需要在“高级选项 / Advanced options”的“网关访问令牌”中输入同一个值。
|
||||
|
||||
如需轮换令牌,重新运行 `npx wrangler secret put ACCESS_TOKEN` 即可。删除令牌使用:
|
||||
|
||||
```bash
|
||||
npx wrangler secret delete ACCESS_TOKEN
|
||||
```
|
||||
|
||||
删除后,在默认 `ALLOW_ANONYMOUS = "false"` 配置下网关会安全地停止签发会话票据。
|
||||
|
||||
### 8. 验证部署
|
||||
|
||||
先检查健康接口:
|
||||
|
||||
```bash
|
||||
curl https://cf-workers-webssh.<your-subdomain>.workers.dev/api/health
|
||||
```
|
||||
|
||||
预期返回:
|
||||
|
||||
```json
|
||||
{"status":"ok","runtime":"cloudflare-workers","ssh":true}
|
||||
```
|
||||
|
||||
然后在浏览器打开 Worker 地址:
|
||||
|
||||
1. 输入 SSH 公网主机、端口和用户名。
|
||||
2. 选择密码或 SSH 密钥认证并输入凭据。
|
||||
3. 展开“高级选项”,输入 `ACCESS_TOKEN`。
|
||||
4. 点击“连接”。第一次连接时,通过可信渠道核对页面显示的 SHA-256 主机指纹。
|
||||
5. 确认后进入交互式 Shell。若勾选记住指纹,后续连接会在当前浏览器中自动固定并比对。
|
||||
|
||||
### 9. 更新部署
|
||||
|
||||
```bash
|
||||
git pull --ff-only
|
||||
npm ci
|
||||
npm run check
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
更新前建议阅读代码差异,尤其是 `wrangler.toml` 中的 Durable Object migration。不要删除已经发布过的 migration tag,也不要随意修改已部署 Durable Object 的类名。
|
||||
|
||||
## 配置项
|
||||
|
||||
| 名称 | 类型 | 默认值 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `ACCESS_TOKEN` | Secret | 未设置 | 推荐的网关访问令牌。只要设置了它,每次创建会话都必须提交正确令牌。 |
|
||||
| `ALLOW_ANONYMOUS` | Variable | `"false"` | 仅在没有 `ACCESS_TOKEN` 时生效;只有精确设置为字符串 `"true"` 才允许匿名创建会话。 |
|
||||
| `CONNECT_TIMEOUT_MS` | Variable | `"10000"` | 每个已验证目标 IP 的 TCP 建连超时,运行时会限制在 2000 到 30000 ms。 |
|
||||
| `SSH_SESSIONS` | Durable Object binding | 已配置 | 每个连接独立的会话对象。不要改名,除非同时修改代码与 migration。 |
|
||||
| `ASSETS` | Workers Assets binding | 已配置 | 将 `dist/` 静态资源交给 Worker 提供。 |
|
||||
|
||||
普通变量可以编辑 `wrangler.toml` 后重新部署。敏感值必须使用 `wrangler secret` 或 Cloudflare Dashboard 的加密 Secret,不要放进 `[vars]`。
|
||||
|
||||
### 匿名模式(不推荐用于公网)
|
||||
|
||||
如果明确接受任何访问者都能借助你的 Worker 发起公网 SSH TCP 连接,可以删除 `ACCESS_TOKEN` 并修改:
|
||||
|
||||
```toml
|
||||
[vars]
|
||||
ALLOW_ANONYMOUS = "true"
|
||||
CONNECT_TIMEOUT_MS = "10000"
|
||||
```
|
||||
|
||||
随后重新运行 `npm run deploy`。公开网关可能被滥用、产生费用或导致 Cloudflare 账户被限制;至少应配合 Cloudflare Access、限流策略和使用监控。
|
||||
|
||||
## 自定义域名与 Cloudflare Access
|
||||
|
||||
### 绑定自定义域名
|
||||
|
||||
在 Cloudflare Dashboard 中进入:
|
||||
|
||||
`Workers & Pages` -> 选择该 Worker -> `Settings` -> `Domains & Routes` -> `Add` -> `Custom domain`
|
||||
|
||||
输入同一 Cloudflare 账户下 Zone 中的域名,例如 `ssh.example.com`。Cloudflare 会创建路由并签发 HTTPS 证书,生效后用以下地址检查:
|
||||
|
||||
```bash
|
||||
curl https://ssh.example.com/api/health
|
||||
```
|
||||
|
||||
也可以将路由写入 `wrangler.toml` 后部署:
|
||||
|
||||
```toml
|
||||
routes = [
|
||||
{ pattern = "ssh.example.com", custom_domain = true }
|
||||
]
|
||||
```
|
||||
|
||||
确认自定义域名可用后,如果不希望用户绕过自定义域名访问 `workers.dev`,将配置改为:
|
||||
|
||||
```toml
|
||||
workers_dev = false
|
||||
```
|
||||
|
||||
然后再次执行 `npm run deploy`。关闭前先确认自定义域名和 Access 策略工作正常,避免把自己锁在外面。
|
||||
|
||||
### 使用 Cloudflare Access
|
||||
|
||||
可在 Cloudflare Zero Trust 中为 `ssh.example.com` 创建 Self-hosted Application,并配置只允许指定用户、邮箱域或身份提供商访问。WebSocket 使用同一站点的 Access 会话,适合保护整个 WebSSH 页面和 API。
|
||||
|
||||
建议采用纵深防御:Cloudflare Access 与 `ACCESS_TOKEN` 同时开启。如果选择只依赖 Access:
|
||||
|
||||
- 确保所有可访问域名都受 Access 保护。
|
||||
- 设置 `workers_dev = false`,避免公开的 `workers.dev` 地址绕过自定义域名策略。
|
||||
- 删除 `ACCESS_TOKEN` 后才需要设置 `ALLOW_ANONYMOUS = "true"`,否则项目自身仍会拒绝签发会话。
|
||||
- 不要仅在前端隐藏令牌输入框;真正的访问控制必须位于 Cloudflare Access 或 Worker 服务端。
|
||||
|
||||
## 通过 Cloudflare Git 集成部署
|
||||
|
||||
不想在本机运行 Wrangler 时,可以使用 Cloudflare 的 Git 集成:
|
||||
|
||||
1. 将项目 Fork 到自己的 GitHub 或 GitLab 账户。
|
||||
2. 在 Cloudflare Dashboard 中进入 `Workers & Pages`,选择创建 Worker 并导入 Git 仓库。
|
||||
3. 项目根目录使用 `/`,Node.js 版本选择 22 或更高。
|
||||
4. Build command 设置为 `npm run build:web`。
|
||||
5. Deploy command 设置为 `npx wrangler deploy`。
|
||||
6. 首次部署完成后,在 Worker 的 `Settings` -> `Variables and Secrets` 中新增加密 Secret:`ACCESS_TOKEN`。
|
||||
7. 再次触发部署,并按前文方式检查 `/api/health` 与实际 SSH 连接。
|
||||
|
||||
Dashboard 的菜单名称可能随 Cloudflare UI 更新而略有变化。关键点是:`wrangler.toml` 必须参与部署、前端必须先生成 `dist/`、`ACCESS_TOKEN` 必须是运行时 Secret 而不是公开构建变量。
|
||||
|
||||
## 本地开发
|
||||
|
||||
### 创建本地变量文件
|
||||
|
||||
复制示例文件为 `.dev.vars`:
|
||||
|
||||
Linux/macOS:
|
||||
|
||||
```bash
|
||||
cp .env.example .dev.vars
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.example .dev.vars
|
||||
```
|
||||
|
||||
编辑 `.dev.vars`:
|
||||
|
||||
```dotenv
|
||||
ACCESS_TOKEN=replace-with-a-long-random-value
|
||||
ALLOW_ANONYMOUS=false
|
||||
CONNECT_TIMEOUT_MS=10000
|
||||
```
|
||||
|
||||
`.dev.vars` 已被 `.gitignore` 忽略。不要提交真实令牌、SSH 密码或私钥。
|
||||
|
||||
### 启动完整本地环境
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
该命令先构建前端,再启动 Wrangler,默认访问 `http://localhost:8787`。本地回环地址允许 HTTP,生产边缘请求仍强制 HTTPS。
|
||||
|
||||
如需前端热更新,使用两个终端:
|
||||
|
||||
终端 1:
|
||||
|
||||
```bash
|
||||
npm run build:web
|
||||
npx wrangler dev
|
||||
```
|
||||
|
||||
终端 2:
|
||||
|
||||
```bash
|
||||
npm run dev:web
|
||||
```
|
||||
|
||||
然后访问 `http://localhost:5173`。Vite 会把 `/api` HTTP 和 WebSocket 请求代理到本地 Wrangler 的 `8787` 端口。
|
||||
|
||||
> [!NOTE]
|
||||
> 本地 Worker 仍然从你的网络连接目标 SSH 服务器;公网目标限制同样生效。不要使用生产 SSH 凭据测试不受信任的代码分支。
|
||||
|
||||
### 常用命令
|
||||
|
||||
| 命令 | 作用 |
|
||||
| --- | --- |
|
||||
| `npm run dev` | 构建前端并启动本地 Worker |
|
||||
| `npm run dev:web` | 启动 Vite 前端开发服务器 |
|
||||
| `npm run build:web` | 将前端构建到 `dist/` |
|
||||
| `npm run typecheck` | 检查 Worker 与前端 TypeScript |
|
||||
| `npm test` | 运行 Node 环境单元测试 |
|
||||
| `npm run test:edge` | 在 Cloudflare Workers 测试池运行边缘加密与安全测试 |
|
||||
| `npm run check` | 执行全部检查、构建和部署 dry-run |
|
||||
| `npm run deploy` | 构建前端并部署到 Cloudflare |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── frontend/
|
||||
│ ├── index.html # WebSSH 页面
|
||||
│ └── src/
|
||||
│ ├── main.ts # xterm、连接状态、配置与 WebSocket 客户端
|
||||
│ └── style.css # 响应式界面样式
|
||||
├── src/
|
||||
│ ├── backend/
|
||||
│ │ ├── durable-object.ts # 会话票据、TCP Socket 与会话生命周期
|
||||
│ │ ├── security.ts # Token、票据、同源与公网目标校验
|
||||
│ │ └── session.ts # SSH 状态机与浏览器消息桥接
|
||||
│ ├── ssh/ # SSH 协议、KEX、密码学、认证与通道实现
|
||||
│ ├── http-security.ts # HTTPS 与安全响应头
|
||||
│ ├── types.ts # Worker 环境、连接消息和 SSH 类型
|
||||
│ └── worker.ts # HTTP/API/Assets 入口
|
||||
├── tests/ # Node 单元测试
|
||||
├── tests-edge/ # Cloudflare 运行时测试
|
||||
├── wrangler.toml # Worker、Assets、Durable Object 与 migration
|
||||
├── vite.config.ts # 前端构建配置
|
||||
└── package.json # 脚本与依赖
|
||||
```
|
||||
|
||||
## API 概览
|
||||
|
||||
这些接口供同源前端使用,不建议作为稳定的公共 API:
|
||||
|
||||
| 接口 | 方法 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `/api/health` | `GET` | 返回 Worker 与 SSH 功能健康状态 |
|
||||
| `/api/session` | `POST` | 校验 `ACCESS_TOKEN` 并创建一次性会话票据 |
|
||||
| `/api/ssh?ticket=...&session=...` | `GET` + WebSocket Upgrade | 进入对应 Durable Object 并建立 SSH 会话 |
|
||||
|
||||
`/api/session` 和 `/api/ssh` 都要求请求 `Origin` 与 Worker URL 同源。票据包含敏感授权能力且只能短时使用,不应记录、复用或转发。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### `503 Gateway access is not configured`
|
||||
|
||||
没有配置 `ACCESS_TOKEN`,且 `ALLOW_ANONYMOUS` 不是 `"true"`。生产环境运行 `npx wrangler secret put ACCESS_TOKEN`;本地开发则检查 `.dev.vars`。
|
||||
|
||||
### `401 Invalid access token`
|
||||
|
||||
页面输入的网关令牌与 Worker Secret 不一致。重新输入,或使用 `npx wrangler secret put ACCESS_TOKEN` 轮换。连接发起后令牌输入框会被清空,这是预期行为。
|
||||
|
||||
### `403 Invalid request origin` / `Invalid WebSocket origin`
|
||||
|
||||
API 只能由同源页面调用。检查是否通过反向代理改写了 `Origin`、协议或 Host,也不要从其他站点直接调用接口。公网部署必须使用 HTTPS/WSS。
|
||||
|
||||
### `Private and reserved SSH targets are disabled`
|
||||
|
||||
目标是内网/保留 IP,或者 DNS 同时解析到公网和私网地址。当前项目有意不提供内网穿透。如果需要访问私网主机,应使用受控 VPN、Cloudflare Tunnel 配合专门的访问架构,或在私网内运行其他 SSH 网关,而不是关闭此校验。
|
||||
|
||||
### TCP 连接超时或被拒绝
|
||||
|
||||
- 确认主机和端口在公网可达,SSH 服务确实监听该端口。
|
||||
- 检查云防火墙、安全组、主机防火墙和入站白名单。
|
||||
- Cloudflare Workers TCP 出口不提供可由本项目固定的单一源 IP;只允许家庭或办公出口 IP 的白名单通常会拒绝连接。
|
||||
- 端口 25 无法使用。需要更长建连时间时,可将 `CONNECT_TIMEOUT_MS` 调高到最多 `30000`。
|
||||
|
||||
### 没有共同的 KEX、Host Key、Cipher 或 MAC
|
||||
|
||||
目标 SSH 服务器没有启用本项目支持的算法。优先在服务端启用现代算法,例如 `curve25519-sha256`、Ed25519/ECDSA/RSA-SHA2 主机密钥和 AES-GCM/CTR。不要为了兼容而重新启用 `ssh-rsa` SHA-1、过时 CBC 或弱 KEX;本项目不会协商这些算法。
|
||||
|
||||
### 私钥无法使用
|
||||
|
||||
- 必须以 `-----BEGIN OPENSSH PRIVATE KEY-----` 开头,而不是 `BEGIN RSA PRIVATE KEY` 或 `BEGIN PRIVATE KEY`。
|
||||
- 私钥不能有口令,且大小不能超过 64 KiB。
|
||||
- 建议新建仅供此网关使用、权限最小化的 Ed25519 密钥,不要移除日常主密钥的口令。
|
||||
|
||||
创建一个无口令的专用 Ed25519 密钥:
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -f webssh_ed25519 -N ""
|
||||
```
|
||||
|
||||
将 `webssh_ed25519.pub` 安装到目标账户的 `~/.ssh/authorized_keys`,页面中只使用私钥文件 `webssh_ed25519`。妥善限制目标账户权限并安全保管私钥。
|
||||
|
||||
### 主机密钥不匹配
|
||||
|
||||
服务器重装、SSH 主机密钥轮换或 DNS 指向变化都可能导致不匹配,也可能代表中间人攻击。不要直接删除已保存指纹;先通过可信渠道核对新指纹。确认是合法轮换后,再更新高级选项中的指纹或清除该站点保存的主机指纹。
|
||||
|
||||
### 会话运行一段时间后因 rekey 断开
|
||||
|
||||
当前实现不支持 SSH rekey。达到服务端的时间或流量阈值后,需要重新连接。只有在了解安全影响并符合组织策略时,才考虑调整服务端 `RekeyLimit`;更推荐保留合理阈值并接受会话重连。
|
||||
|
||||
### Worker 更新时会话断开
|
||||
|
||||
这是预期行为。部署新版本或 Durable Object 被重新调度后,现有 SSH 流状态无法恢复,浏览器会收到重连提示。部署前请避免在 WebSSH 中运行无法恢复的重要前台任务,或先进入 `tmux`/`screen`。
|
||||
|
||||
## 运维建议
|
||||
|
||||
- 使用 Cloudflare Access 和 `ACCESS_TOKEN` 双重保护,关闭不需要的 `workers.dev` 公网入口。
|
||||
- 为 WebSSH 创建独立、低权限 SSH 账户和密钥;敏感运维使用 `sudo` 审计与最小授权。
|
||||
- 定期轮换 `ACCESS_TOKEN`,开启 Cloudflare 账户 MFA,并限制部署 API Token 权限。
|
||||
- 监控 Worker、Durable Objects 和网络使用量。每个活动终端都会占用 Durable Object 与出站 TCP 连接,费用和限制以 Cloudflare 当前套餐为准。
|
||||
- 更新前执行 `npm run check`,保留已经发布的 Durable Object migrations,并在低峰期部署。
|
||||
- 不要在 URL、Issue、日志、截图或分享链接中放入密码、私钥、网关令牌或一次性票据。
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 [Apache License 2.0](LICENSE)。
|
||||
@@ -0,0 +1,239 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#080b10" />
|
||||
<meta name="description" content="安全的 Cloudflare Workers 原生 WebSSH 终端 / A secure, native Cloudflare Workers WebSSH terminal" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>Workers WebSSH</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header class="topbar">
|
||||
<div class="brand-group">
|
||||
<button id="panel-toggle" class="icon-button mobile-only" type="button" aria-label="打开连接面板 / Open connection panel" aria-controls="connection-panel" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16" /></svg>
|
||||
</button>
|
||||
<a class="brand" href="/" aria-label="Workers WebSSH 首页 / Workers WebSSH home">
|
||||
<span class="brand-mark" aria-hidden="true">>_</span>
|
||||
<span><strong>Workers</strong> WebSSH</span>
|
||||
</a>
|
||||
<span class="runtime-badge">边缘原生 / EDGE NATIVE</span>
|
||||
</div>
|
||||
|
||||
<div class="topbar-actions">
|
||||
<div id="global-status" class="status-pill" data-state="idle" role="status" aria-live="polite">
|
||||
<span class="status-dot"></span>
|
||||
<span id="global-status-label">离线 / Offline</span>
|
||||
</div>
|
||||
<button id="theme-toggle" class="icon-button" type="button" aria-label="切换颜色主题 / Toggle color theme">
|
||||
<svg class="sun-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.65 17.65l1.42 1.42M2 12h2M20 12h2M4.93 19.07l1.42-1.42M17.65 6.35l1.42-1.42" /></svg>
|
||||
</button>
|
||||
<a class="icon-button" href="https://github.com/cmliu/CF-Workers-WebSSH" target="_blank" rel="noreferrer" aria-label="打开源代码仓库 / Open source repository">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48v-1.87c-2.78.6-3.37-1.18-3.37-1.18-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.9 1.53 2.35 1.09 2.92.83.09-.65.35-1.09.64-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.64 0 0 .84-.27 2.75 1.02A9.56 9.56 0 0 1 12 6.82c.85 0 1.71.11 2.51.34 1.91-1.29 2.75-1.02 2.75-1.02.55 1.37.2 2.39.1 2.64.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.68-4.57 4.93.36.31.68.92.68 1.86v2.76c0 .27.18.58.69.48A10 10 0 0 0 12 2Z" /></svg>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="app-shell">
|
||||
<button id="panel-scrim" class="panel-scrim" type="button" aria-label="关闭连接面板 / Close connection panel" hidden></button>
|
||||
<aside id="connection-panel" class="connection-panel" aria-label="连接管理 / Connection management">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">连接管理 / CONNECTIONS</p>
|
||||
<h1>SSH 工作台 <small>SSH workspace</small></h1>
|
||||
</div>
|
||||
<button id="new-profile" class="small-button" type="button">新建 / New</button>
|
||||
</div>
|
||||
|
||||
<section class="profiles-section" aria-labelledby="profiles-title">
|
||||
<div class="section-title-row">
|
||||
<h2 id="profiles-title">已保存目标 / Saved targets</h2>
|
||||
<span id="profile-count" class="count-badge">0</span>
|
||||
</div>
|
||||
<div id="profile-list" class="profile-list">
|
||||
<p class="empty-list">暂无已保存目标 / No saved targets yet.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form id="connection-form" class="connection-form" autocomplete="off" novalidate>
|
||||
<input id="profile-id" type="hidden" />
|
||||
<div class="field-grid single">
|
||||
<label class="field">
|
||||
<span>连接名称 <small>Profile name</small></span>
|
||||
<input id="profile-name" name="profileName" type="text" maxlength="64" placeholder="生产环境 / Production" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field-grid host-grid">
|
||||
<label class="field">
|
||||
<span>主机 <small>Host</small></span>
|
||||
<input id="host" name="host" type="text" maxlength="253" placeholder="ssh.example.com" required spellcheck="false" />
|
||||
</label>
|
||||
<label class="field port-field">
|
||||
<span>端口 <small>Port</small></span>
|
||||
<input id="port" name="port" type="number" value="22" min="1" max="65535" required inputmode="numeric" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field-grid single">
|
||||
<label class="field">
|
||||
<span>用户名 <small>Username</small></span>
|
||||
<input id="username" name="username" type="text" maxlength="128" placeholder="root" required autocomplete="username" spellcheck="false" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset class="auth-fieldset">
|
||||
<legend>身份认证 / Authentication</legend>
|
||||
<div class="segmented-control" role="radiogroup" aria-label="身份认证方式 / Authentication method">
|
||||
<label><input type="radio" name="authMethod" value="password" checked /><span>密码 / Password</span></label>
|
||||
<label><input type="radio" name="authMethod" value="publickey" /><span>SSH 密钥 / SSH key</span></label>
|
||||
</div>
|
||||
|
||||
<div id="password-field" class="field secret-field">
|
||||
<label for="password">密码 <small>Password · 永不存储 / never stored</small></label>
|
||||
<span class="input-action-wrap">
|
||||
<input id="password" name="password" type="password" maxlength="4096" autocomplete="current-password" />
|
||||
<button id="reveal-password" class="input-action" type="button" aria-label="显示密码 / Show password">显示 / Show</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div id="key-field" class="key-field" hidden>
|
||||
<label class="field">
|
||||
<span>私钥 <small>Private key · 未加密 OpenSSH / unencrypted OpenSSH</small></span>
|
||||
<textarea id="private-key" name="privateKey" rows="5" maxlength="65536" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea>
|
||||
</label>
|
||||
<label class="file-picker">
|
||||
<input id="key-file" type="file" accept=".pem,.key,.openssh,text/plain" />
|
||||
<span>选择密钥文件 / Choose key file</span>
|
||||
<small id="key-file-name">未选择文件 / No file selected</small>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<details class="advanced-options">
|
||||
<summary>高级选项 / Advanced options</summary>
|
||||
<div class="advanced-body">
|
||||
<label class="field">
|
||||
<span>初始命令 <small>Initial command</small></span>
|
||||
<input id="initial-command" name="initialCommand" type="text" maxlength="4096" placeholder="tmux attach || tmux" spellcheck="false" />
|
||||
</label>
|
||||
<div class="field-grid two">
|
||||
<label class="field">
|
||||
<span>终端类型 <small>Terminal</small></span>
|
||||
<select id="term-type" name="termType">
|
||||
<option value="xterm-256color">xterm-256color</option>
|
||||
<option value="xterm">xterm</option>
|
||||
<option value="screen-256color">screen-256color</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>字符编码 <small>Encoding</small></span>
|
||||
<select id="encoding" name="encoding">
|
||||
<option value="utf-8">UTF-8</option>
|
||||
<option value="gb18030">GB18030</option>
|
||||
<option value="big5">Big5</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>预期主机指纹 <small>Expected host fingerprint · SHA-256</small></span>
|
||||
<input id="fingerprint" name="fingerprint" type="text" maxlength="128" placeholder="首次使用后自动保存 / Saved after first use" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>网关访问令牌 <small>Gateway access token · 可选且永不存储 / optional, never stored</small></span>
|
||||
<input id="access-token" name="accessToken" type="password" maxlength="4096" autocomplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div id="form-error" class="form-error" role="alert" hidden></div>
|
||||
<div class="form-actions">
|
||||
<button id="connect-button" class="primary-button" type="submit">
|
||||
<span class="button-icon" aria-hidden="true">>_</span>
|
||||
<span>连接 / Connect</span>
|
||||
</button>
|
||||
<button id="save-profile" class="secondary-button" type="button">保存 / Save</button>
|
||||
<button id="share-link" class="icon-button bordered" type="button" aria-label="复制安全连接链接 / Copy safe connection link">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="privacy-note">
|
||||
凭据仅通过本页面的 TLS WebSocket 传输,并只在当前会话内存中保留。 / Credentials travel only through this page's TLS WebSocket and remain in memory for the active session.
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
<main class="workspace">
|
||||
<section class="session-strip" aria-label="实时连接状态 / Live connection status">
|
||||
<div class="session-target">
|
||||
<span class="live-orb" id="live-orb"></span>
|
||||
<div>
|
||||
<strong id="session-title">无活动会话 / No active session</strong>
|
||||
<span id="session-subtitle">选择目标并连接 / Choose a target and connect</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-metrics">
|
||||
<div><span>边缘 / EDGE</span><strong id="metric-edge">--</strong></div>
|
||||
<div><span>延迟 / RTT</span><strong id="metric-rtt">--</strong></div>
|
||||
<div><span>时长 / UPTIME</span><strong id="metric-uptime">00:00</strong></div>
|
||||
<div><span>主机密钥 / HOST KEY</span><strong id="metric-host-key">--</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="terminal-card" class="terminal-card">
|
||||
<div class="terminal-toolbar">
|
||||
<div class="window-controls" aria-hidden="true"><i></i><i></i><i></i></div>
|
||||
<div id="terminal-label" class="terminal-label">终端 / terminal · 等待 / waiting</div>
|
||||
<div class="terminal-actions">
|
||||
<button id="clear-terminal" class="toolbar-button" type="button">清屏 / Clear</button>
|
||||
<button id="fullscreen-terminal" class="toolbar-button" type="button">全屏 / Fullscreen</button>
|
||||
<button id="disconnect-button" class="toolbar-button danger" type="button" disabled>断开 / Disconnect</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="terminal-stage" class="terminal-stage">
|
||||
<div id="terminal" role="application" aria-label="SSH 终端 / SSH terminal"></div>
|
||||
<div id="terminal-empty" class="terminal-empty">
|
||||
<div class="empty-glyph" aria-hidden="true">>_</div>
|
||||
<h2>终端已就绪 / Your terminal is ready</h2>
|
||||
<p>从连接面板建立 SSH 连接,开始加密的边缘会话。<br /><span lang="en">Open an SSH connection from the panel to start an encrypted edge session.</span></p>
|
||||
<button id="empty-connect" class="small-button" type="button">配置连接 / Configure connection</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="event-bar">
|
||||
<span id="event-message">Worker 运行时待命 / Worker runtime standing by</span>
|
||||
<button id="event-toggle" type="button" aria-expanded="false">会话日志 / Session log</button>
|
||||
</div>
|
||||
<div id="event-log" class="event-log" hidden aria-live="polite"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="toast-region" class="toast-region" aria-live="polite"></div>
|
||||
|
||||
<dialog id="host-key-dialog" class="host-key-dialog" aria-labelledby="host-key-title">
|
||||
<form method="dialog">
|
||||
<span class="dialog-icon" aria-hidden="true">密钥<br />KEY</span>
|
||||
<div>
|
||||
<p class="eyebrow">主机身份 / HOST IDENTITY</p>
|
||||
<h2 id="host-key-title">信任此 SSH 主机? / Trust this SSH host?</h2>
|
||||
<p>服务器提供了尚未为此目标固定的密钥。继续前,请通过可信渠道核验。<br /><span lang="en">The server presented a key that has not been pinned for this target. Verify it through a trusted channel before continuing.</span></p>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>目标 / Target</dt><dd id="host-key-target">--</dd></div>
|
||||
<div><dt>密钥类型 / Key type</dt><dd id="host-key-type">--</dd></div>
|
||||
<div><dt>SHA-256 指纹 / fingerprint</dt><dd id="host-key-fingerprint">--</dd></div>
|
||||
</dl>
|
||||
<label class="trust-check"><input id="remember-host-key" type="checkbox" checked /> 下次记住并验证此指纹 / Remember and verify this fingerprint next time</label>
|
||||
<div class="dialog-actions">
|
||||
<button id="reject-host-key" class="secondary-button" value="reject" type="submit">取消连接 / Cancel</button>
|
||||
<button id="accept-host-key" class="primary-button" value="accept" type="submit">信任并继续 / Trust & continue</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#080b10" />
|
||||
<rect x="5" y="5" width="54" height="54" rx="10" fill="#111820" stroke="#69e6b4" stroke-width="2" />
|
||||
<path d="m17 20 11 11-11 11M32 43h15" fill="none" stroke="#69e6b4" stroke-linecap="round" stroke-linejoin="round" stroke-width="5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 364 B |
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Generated
+2594
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "cf-workers-webssh",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "A dependency-free-at-runtime SSH terminal for Cloudflare Workers",
|
||||
"scripts": {
|
||||
"dev": "npm run build:web && wrangler dev",
|
||||
"dev:web": "vite --config vite.config.ts",
|
||||
"build:web": "vite build --config vite.config.ts",
|
||||
"typecheck": "tsc -p tsconfig.worker.json --noEmit && tsc -p tsconfig.frontend.json --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:edge": "vitest run --config vitest.edge.config.ts",
|
||||
"check": "npm run typecheck && npm run test && npm run test:edge && npm run build:web && wrangler deploy --dry-run --outdir .wrangler/build",
|
||||
"deploy": "npm run build:web && wrangler deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.18.8",
|
||||
"@cloudflare/workers-types": "^5.20260727.1",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.0.6",
|
||||
"vitest": "^4.1.10",
|
||||
"wrangler": "^4.114.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { connect } from 'cloudflare:sockets';
|
||||
import { parseConnectMessage, type Env } from '../types';
|
||||
import { assertPublicTarget } from './security';
|
||||
import { createTicket, verifyTicket } from './security';
|
||||
import { SSHSession } from './session';
|
||||
|
||||
interface Attachment { phase: 'waiting' | 'connecting' | 'connected' }
|
||||
interface StoredTicket { secret: number[]; expiresAt: number; ip: string }
|
||||
interface PendingConnection {
|
||||
cancelled: boolean;
|
||||
socket?: Socket;
|
||||
closedSockets: WeakSet<Socket>;
|
||||
}
|
||||
const TICKET_STORAGE_KEY = 'session-ticket';
|
||||
|
||||
export class SSHSessionDO implements DurableObject {
|
||||
private readonly state: DurableObjectState;
|
||||
private readonly env: Env;
|
||||
private readonly sessions = new Map<WebSocket, SSHSession>();
|
||||
private readonly pendingConnections = new Map<WebSocket, PendingConnection>();
|
||||
private readonly deadlines = new Map<WebSocket, ReturnType<typeof setTimeout>>();
|
||||
|
||||
constructor(state: DurableObjectState, env: Env) {
|
||||
this.state = state;
|
||||
this.env = env;
|
||||
for (const ws of state.getWebSockets()) {
|
||||
const attachment = ws.deserializeAttachment() as Attachment | null;
|
||||
if (attachment?.phase === 'connected') {
|
||||
try { ws.close(1012, 'Worker session restarted; reconnect required'); } catch { /* already closed */ }
|
||||
} else {
|
||||
try { ws.close(1008, 'Session state expired'); } catch { /* already closed */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === '/ticket' && request.method === 'POST') {
|
||||
const ip = request.headers.get('x-client-ip') ?? 'unknown';
|
||||
if (!/^[0-9a-f:.]{2,64}$|^local$|^unknown$/i.test(ip)) return Response.json({ error: 'Invalid client address' }, { status: 400 });
|
||||
const secret = crypto.getRandomValues(new Uint8Array(32));
|
||||
const created = await createTicket(secret, ip);
|
||||
const stored = await this.state.storage.transaction(async (tx) => {
|
||||
if (await tx.get(TICKET_STORAGE_KEY)) return false;
|
||||
await tx.put(TICKET_STORAGE_KEY, { secret: Array.from(secret), expiresAt: created.expiresAt, ip } satisfies StoredTicket);
|
||||
await tx.setAlarm(created.expiresAt);
|
||||
return true;
|
||||
});
|
||||
secret.fill(0);
|
||||
return stored ? Response.json(created) : Response.json({ error: 'Ticket already created' }, { status: 409 });
|
||||
}
|
||||
if (url.pathname !== '/connect') return Response.json({ error: 'Not found' }, { status: 404 });
|
||||
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') return Response.json({ error: 'WebSocket upgrade required' }, { status: 426 });
|
||||
const ticket = request.headers.get('x-session-ticket');
|
||||
const ip = request.headers.get('x-client-ip') ?? 'unknown';
|
||||
if (!ticket || !await this.consumeTicket(ticket, ip)) {
|
||||
return Response.json({ error: 'Invalid session ticket' }, { status: 401 });
|
||||
}
|
||||
const pair = new WebSocketPair();
|
||||
const client = pair[0];
|
||||
const server = pair[1];
|
||||
this.state.acceptWebSocket(server);
|
||||
server.serializeAttachment({ phase: 'waiting' } satisfies Attachment);
|
||||
const deadline = setTimeout(() => this.reject(server, 'Connect message timeout'), 10_000);
|
||||
this.deadlines.set(server, deadline);
|
||||
return new Response(null, { status: 101, webSocket: client });
|
||||
}
|
||||
|
||||
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
||||
try {
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
this.cleanup(ws);
|
||||
return;
|
||||
}
|
||||
const session = this.sessions.get(ws);
|
||||
if (session) {
|
||||
await session.handleClientMessage(message);
|
||||
return;
|
||||
}
|
||||
if (this.pendingConnections.has(ws)) throw new Error('An SSH connection is already being initialized');
|
||||
if (typeof message !== 'string' || message.length > 160 * 1024) throw new Error('The first WebSocket message must be a connect JSON object');
|
||||
let decoded: unknown;
|
||||
try { decoded = JSON.parse(message); } catch { throw new Error('Invalid connect JSON'); }
|
||||
const config = parseConnectMessage(decoded);
|
||||
const pending: PendingConnection = { cancelled: false, closedSockets: new WeakSet() };
|
||||
this.pendingConnections.set(ws, pending);
|
||||
ws.serializeAttachment({ phase: 'connecting' } satisfies Attachment);
|
||||
this.clearDeadline(ws);
|
||||
if (config.port === 25) throw new Error('Cloudflare Workers cannot connect to outbound TCP port 25');
|
||||
const verifiedAddresses = await assertPublicTarget(config.host);
|
||||
this.assertConnectionActive(ws, pending);
|
||||
ws.send(JSON.stringify({ type: 'status', event: 'tcp_connecting', message: `Connecting to ${config.host}:${config.port}` }));
|
||||
|
||||
// Connect to the exact address that passed SSRF validation. Keeping the
|
||||
// original hostname only for host-key pinning prevents DNS rebinding.
|
||||
const socket = await this.openVerifiedAddress(verifiedAddresses, config.port, pending);
|
||||
this.assertConnectionActive(ws, pending, socket);
|
||||
|
||||
const ssh = new SSHSession(ws, socket, config);
|
||||
this.sessions.set(ws, ssh);
|
||||
this.pendingConnections.delete(ws);
|
||||
pending.socket = undefined;
|
||||
ws.serializeAttachment({ phase: 'connected' } satisfies Attachment);
|
||||
await ssh.start();
|
||||
} catch (error) {
|
||||
this.reject(ws, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async webSocketClose(ws: WebSocket): Promise<void> { this.cleanup(ws); }
|
||||
async webSocketError(ws: WebSocket): Promise<void> { this.cleanup(ws); }
|
||||
|
||||
async alarm(): Promise<void> {
|
||||
await this.state.storage.delete(TICKET_STORAGE_KEY);
|
||||
}
|
||||
|
||||
private connectTimeout(): number {
|
||||
const configured = Number(this.env.CONNECT_TIMEOUT_MS ?? 10_000);
|
||||
return Number.isFinite(configured) ? Math.min(30_000, Math.max(2_000, Math.floor(configured))) : 10_000;
|
||||
}
|
||||
|
||||
private async consumeTicket(ticket: string, ip: string): Promise<boolean> {
|
||||
return this.state.storage.transaction(async (tx) => {
|
||||
const stored = await tx.get<StoredTicket>(TICKET_STORAGE_KEY);
|
||||
if (!stored) return false;
|
||||
// A presented ticket is one-shot even when malformed, which closes the
|
||||
// replay race without retaining authorization material after an attempt.
|
||||
await tx.delete(TICKET_STORAGE_KEY);
|
||||
await tx.deleteAlarm();
|
||||
if (stored.ip !== ip || stored.expiresAt < Date.now() || stored.secret.length !== 32) return false;
|
||||
const secret = new Uint8Array(stored.secret);
|
||||
try {
|
||||
return await verifyTicket(secret, ticket, ip);
|
||||
} finally {
|
||||
secret.fill(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async openVerifiedAddress(addresses: string[], port: number, pending: PendingConnection): Promise<Socket> {
|
||||
let lastError: unknown = new Error('No verified target address is available');
|
||||
for (const hostname of addresses) {
|
||||
if (pending.cancelled) throw new Error('SSH connection attempt was cancelled');
|
||||
const socket = connect({ hostname, port }, { secureTransport: 'off', allowHalfOpen: false });
|
||||
pending.socket = socket;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
socket.opened,
|
||||
new Promise<never>((_, reject) => { timeoutId = setTimeout(() => reject(new Error('TCP connection timed out')), this.connectTimeout()); }),
|
||||
]);
|
||||
if (pending.cancelled) throw new Error('SSH connection attempt was cancelled');
|
||||
return socket;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
this.closePendingSocket(pending, socket);
|
||||
if (pending.cancelled) throw error;
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
private reject(ws: WebSocket, message: string): void {
|
||||
try { ws.send(JSON.stringify({ type: 'error', event: 'connection_failed', message })); } catch { /* already closed */ }
|
||||
this.cleanup(ws);
|
||||
try { ws.close(1011, 'SSH connection failed'); } catch { /* already closed */ }
|
||||
}
|
||||
|
||||
private clearDeadline(ws: WebSocket): void {
|
||||
const deadline = this.deadlines.get(ws);
|
||||
if (deadline) clearTimeout(deadline);
|
||||
this.deadlines.delete(ws);
|
||||
}
|
||||
|
||||
private cleanup(ws: WebSocket): void {
|
||||
this.clearDeadline(ws);
|
||||
const pending = this.pendingConnections.get(ws);
|
||||
if (pending) {
|
||||
pending.cancelled = true;
|
||||
this.closePendingSocket(pending);
|
||||
this.pendingConnections.delete(ws);
|
||||
}
|
||||
this.sessions.get(ws)?.close(true);
|
||||
this.sessions.delete(ws);
|
||||
}
|
||||
|
||||
private assertConnectionActive(ws: WebSocket, pending: PendingConnection, socket?: Socket): void {
|
||||
if (!pending.cancelled && this.pendingConnections.get(ws) === pending && ws.readyState === WebSocket.OPEN) return;
|
||||
if (socket) this.closePendingSocket(pending, socket);
|
||||
throw new Error('SSH connection attempt was cancelled');
|
||||
}
|
||||
|
||||
private closePendingSocket(pending: PendingConnection, socket = pending.socket): void {
|
||||
if (!socket || pending.closedSockets.has(socket)) return;
|
||||
pending.closedSockets.add(socket);
|
||||
if (pending.socket === socket) pending.socket = undefined;
|
||||
try { void socket.close().catch(() => undefined); } catch { /* already closed */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
const TICKET_TTL_MS = 60_000;
|
||||
|
||||
interface TicketPayload {
|
||||
exp: number;
|
||||
nonce: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
function toBase64Url(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function fromBase64Url(value: string): Uint8Array {
|
||||
if (!/^[A-Za-z0-9_-]*$/.test(value)) throw new Error('Invalid Base64URL value');
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
|
||||
const binary = atob(padded);
|
||||
if (toBase64Url(Uint8Array.from(binary, (char) => char.charCodeAt(0))) !== value) throw new Error('Non-canonical Base64URL value');
|
||||
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
}
|
||||
|
||||
export function constantTimeEqual(left: string, right: string): boolean {
|
||||
const a = encoder.encode(left);
|
||||
const b = encoder.encode(right);
|
||||
const length = Math.max(a.length, b.length);
|
||||
let difference = a.length ^ b.length;
|
||||
for (let index = 0; index < length; index++) difference |= (a[index] ?? 0) ^ (b[index] ?? 0);
|
||||
return difference === 0;
|
||||
}
|
||||
|
||||
export async function createTicket(secret: Uint8Array, ip: string): Promise<{ ticket: string; expiresAt: number }> {
|
||||
const expiresAt = Date.now() + TICKET_TTL_MS;
|
||||
const payload: TicketPayload = { exp: expiresAt, nonce: crypto.randomUUID(), ip };
|
||||
const encodedPayload = toBase64Url(encoder.encode(JSON.stringify(payload)));
|
||||
const key = await crypto.subtle.importKey('raw', new Uint8Array(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||
const signature = new Uint8Array(await crypto.subtle.sign('HMAC', key, encoder.encode(encodedPayload)));
|
||||
return { ticket: `${encodedPayload}.${toBase64Url(signature)}`, expiresAt };
|
||||
}
|
||||
|
||||
export async function verifyTicket(secret: Uint8Array, ticket: string, ip: string): Promise<boolean> {
|
||||
const parts = ticket.split('.');
|
||||
if (parts.length !== 2 || parts[0].length > 1024 || parts[1].length > 128) return false;
|
||||
try {
|
||||
const key = await crypto.subtle.importKey('raw', new Uint8Array(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
||||
const valid = await crypto.subtle.verify('HMAC', key, new Uint8Array(fromBase64Url(parts[1])), encoder.encode(parts[0]));
|
||||
if (!valid) return false;
|
||||
const payload = JSON.parse(decoder.decode(fromBase64Url(parts[0]))) as Partial<TicketPayload>;
|
||||
return typeof payload.exp === 'number' && Number.isFinite(payload.exp)
|
||||
&& payload.exp >= Date.now() && payload.exp <= Date.now() + TICKET_TTL_MS + 5000
|
||||
&& payload.ip === ip && typeof payload.nonce === 'string'
|
||||
&& /^[0-9a-f-]{36}$/i.test(payload.nonce);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isSameOrigin(request: Request): boolean {
|
||||
const origin = request.headers.get('Origin');
|
||||
return origin !== null && origin === new URL(request.url).origin;
|
||||
}
|
||||
|
||||
function isIPv4(host: string): boolean {
|
||||
return /^(?:\d{1,3}\.){3}\d{1,3}$/.test(host)
|
||||
&& host.split('.').every((part) => Number(part) <= 255 && (part === '0' || !part.startsWith('0')));
|
||||
}
|
||||
|
||||
function normalizeIPv6(host: string): string {
|
||||
try {
|
||||
return new URL(`http://[${host}]`).hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
||||
} catch {
|
||||
return host.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
export function isPrivateAddress(host: string): boolean {
|
||||
const value = host.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
if (value === 'localhost' || value.endsWith('.localhost') || value === '0.0.0.0'
|
||||
|| value === 'metadata.google.internal' || value.endsWith('.internal')) return true;
|
||||
if (isIPv4(value)) {
|
||||
const [a, b, c] = value.split('.').map(Number);
|
||||
return a === 0 || a === 10 || a === 127
|
||||
|| (a === 100 && b >= 64 && b <= 127)
|
||||
|| (a === 169 && b === 254)
|
||||
|| (a === 172 && b >= 16 && b <= 31)
|
||||
|| (a === 192 && b === 0 && c === 0)
|
||||
|| (a === 192 && b === 0 && c === 2)
|
||||
|| (a === 192 && b === 88 && c === 99)
|
||||
|| (a === 192 && b === 168)
|
||||
|| (a === 198 && (b === 18 || b === 19))
|
||||
|| (a === 198 && b === 51 && c === 100)
|
||||
|| (a === 203 && b === 0 && c === 113)
|
||||
|| a >= 224;
|
||||
}
|
||||
if (!value.includes(':')) return false;
|
||||
const ipv6 = normalizeIPv6(value);
|
||||
if (ipv6 === '::' || ipv6 === '::1'
|
||||
|| /^f[cd]/.test(ipv6)
|
||||
|| /^fe[89ab]/.test(ipv6)
|
||||
|| ipv6.startsWith('2001:db8:')
|
||||
|| ipv6.startsWith('ff')) return true;
|
||||
if (ipv6.startsWith('::ffff:')) {
|
||||
const mapped = ipv6.slice('::ffff:'.length);
|
||||
if (isIPv4(mapped)) return isPrivateAddress(mapped);
|
||||
const halves = mapped.split(':');
|
||||
if (halves.length === 2 && halves.every((part) => /^[0-9a-f]{1,4}$/.test(part))) {
|
||||
const numeric = (Number.parseInt(halves[0], 16) * 0x10000 + Number.parseInt(halves[1], 16)) >>> 0;
|
||||
return isPrivateAddress(`${numeric >>> 24}.${numeric >>> 16 & 0xff}.${numeric >>> 8 & 0xff}.${numeric & 0xff}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface DnsAnswer { type: number; data: string }
|
||||
|
||||
export async function resolvePublicAddresses(host: string): Promise<string[]> {
|
||||
if (host.includes(':') || isIPv4(host)) return [host];
|
||||
const endpoint = 'https://cloudflare-dns.com/dns-query';
|
||||
const headers = { Accept: 'application/dns-json' };
|
||||
const responses = await Promise.all(['A', 'AAAA'].map((type) => fetch(`${endpoint}?name=${encodeURIComponent(host)}&type=${type}`, {
|
||||
headers,
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})));
|
||||
const addresses: string[] = [];
|
||||
for (const response of responses) {
|
||||
if (!response.ok) throw new Error('Unable to verify the target DNS records');
|
||||
const responseUrl = new URL(response.url);
|
||||
if (responseUrl.origin !== 'https://cloudflare-dns.com'
|
||||
|| !responseUrl.pathname.startsWith('/dns-query')
|
||||
|| !response.headers.get('Content-Type')?.toLowerCase().startsWith('application/dns-json')) {
|
||||
throw new Error('Unexpected DNS resolver response');
|
||||
}
|
||||
const result = await response.json<{ Status: number; Answer?: DnsAnswer[] }>();
|
||||
if (result.Status !== 0 && result.Status !== 3) throw new Error('Unable to verify the target DNS records');
|
||||
if ((result.Answer?.length ?? 0) > 64) throw new Error('Target DNS response has too many records');
|
||||
for (const answer of result.Answer ?? []) {
|
||||
if (answer.type === 1 && isIPv4(answer.data)) addresses.push(answer.data);
|
||||
if (answer.type === 28 && answer.data.includes(':')) {
|
||||
const normalized = normalizeIPv6(answer.data);
|
||||
if (!normalized.includes('%') && normalized.includes(':')) addresses.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
|
||||
export async function assertPublicTarget(host: string): Promise<string[]> {
|
||||
if (isPrivateAddress(host)) throw new Error('Private and reserved SSH targets are disabled');
|
||||
const addresses = await resolvePublicAddresses(host);
|
||||
if (addresses.length === 0) throw new Error('SSH target did not resolve to an address');
|
||||
for (const address of addresses) if (isPrivateAddress(address)) throw new Error('Target DNS resolves to a private or reserved address');
|
||||
const unique = [...new Set(addresses.map((address) => address.toLowerCase()))];
|
||||
if (unique.length > 16) throw new Error('SSH target resolves to too many addresses');
|
||||
return unique;
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
import {
|
||||
SSH_MSG_CHANNEL_CLOSE,
|
||||
SSH_MSG_CHANNEL_DATA,
|
||||
SSH_MSG_CHANNEL_EOF,
|
||||
SSH_MSG_CHANNEL_EXTENDED_DATA,
|
||||
SSH_MSG_CHANNEL_FAILURE,
|
||||
SSH_MSG_CHANNEL_OPEN_CONFIRMATION,
|
||||
SSH_MSG_CHANNEL_OPEN_FAILURE,
|
||||
SSH_MSG_CHANNEL_SUCCESS,
|
||||
SSH_MSG_CHANNEL_WINDOW_ADJUST,
|
||||
SSH_MSG_DEBUG,
|
||||
SSH_MSG_DISCONNECT,
|
||||
SSH_MSG_EXT_INFO,
|
||||
SSH_MSG_GLOBAL_REQUEST,
|
||||
SSH_MSG_IGNORE,
|
||||
SSH_MSG_KEXINIT,
|
||||
SSH_MSG_KEX_ECDH_REPLY,
|
||||
SSH_MSG_NEWKEYS,
|
||||
SSH_MSG_REQUEST_FAILURE,
|
||||
SSH_MSG_REQUEST_SUCCESS,
|
||||
SSH_MSG_SERVICE_ACCEPT,
|
||||
SSH_MSG_SERVICE_REQUEST,
|
||||
SSH_MSG_UNIMPLEMENTED,
|
||||
SSH_MSG_USERAUTH_FAILURE,
|
||||
SSH_MSG_USERAUTH_SUCCESS,
|
||||
type SSHConnectionConfig,
|
||||
type SSHPacket,
|
||||
type SessionKeys,
|
||||
} from '../types';
|
||||
import { isSSH2Identification, SSHTransport } from '../ssh/transport';
|
||||
import { SSHPacketBuilder, SSHPacketParser, nextSequenceNumber } from '../ssh/packet';
|
||||
import { KEXInitBuilder, filterExtInfo, negotiate, parseKEXInit, parseServerSigAlgs } from '../ssh/kex';
|
||||
import {
|
||||
KEX_ALGORITHM_ECDH_NISTP256,
|
||||
getCipherSpec,
|
||||
getMacSpec,
|
||||
isCurve25519KEXAlgorithm,
|
||||
} from '../ssh/algorithms';
|
||||
import { ECDHKeyExchange } from '../ssh/kex-ecdh';
|
||||
import { Curve25519KeyExchange, type Curve25519KeyPair } from '../ssh/kex-curve25519';
|
||||
import { KeyDerivation } from '../ssh/keys';
|
||||
import { SSHAESCTRCipher, SSHAESGCMCipher, SSHHMAC } from '../ssh/crypto';
|
||||
import { SSHAuth } from '../ssh/auth';
|
||||
import { SSHChannel, type ChannelDataChunk } from '../ssh/channel';
|
||||
import { encodeString, readUint32, toBufferSource } from '../ssh/utils';
|
||||
|
||||
type Cipher = SSHAESGCMCipher | SSHAESCTRCipher;
|
||||
type Phase = 'version' | 'kex' | 'host-confirm' | 'auth' | 'pty' | 'shell' | 'ready' | 'closed';
|
||||
const LOCAL_WINDOW_THRESHOLD = 512 * 1024;
|
||||
const MAX_VERSION_BYTES = 8192;
|
||||
const MAX_QUEUED_INPUT = 1024 * 1024;
|
||||
const KEEPALIVE_NAME = new TextEncoder().encode('keepalive@openssh.com');
|
||||
|
||||
export class SSHSession {
|
||||
private readonly encoder = new TextEncoder();
|
||||
private readonly decoder = new TextDecoder();
|
||||
private readonly transport = new SSHTransport();
|
||||
private readonly parser = new SSHPacketParser();
|
||||
private readonly channel = new SSHChannel();
|
||||
private readonly config: SSHConnectionConfig;
|
||||
private readonly ws: WebSocket;
|
||||
private readonly socket: Socket;
|
||||
private phase: Phase = 'version';
|
||||
private versionBuffer = new Uint8Array();
|
||||
private writer: WritableStreamDefaultWriter<Uint8Array> | null = null;
|
||||
private sendSequence = 0;
|
||||
private sendChain: Promise<void> = Promise.resolve();
|
||||
private localKex: Uint8Array | null = null;
|
||||
private remoteKex: Uint8Array | null = null;
|
||||
private kexName: string | null = null;
|
||||
private hostKeyAlgorithm: string | null = null;
|
||||
private ecdhPair: CryptoKeyPair | null = null;
|
||||
private curvePair: Curve25519KeyPair | null = null;
|
||||
private clientPublic: Uint8Array | null = null;
|
||||
private sessionId: Uint8Array | null = null;
|
||||
private keys: SessionKeys | null = null;
|
||||
private cipherC2S = 'aes128-gcm@openssh.com';
|
||||
private cipherS2C = 'aes128-gcm@openssh.com';
|
||||
private macC2S = 'none';
|
||||
private macS2C = 'none';
|
||||
private encryptor: Cipher | null = null;
|
||||
private decryptor: Cipher | null = null;
|
||||
private signer: SSHHMAC | null = null;
|
||||
private verifier: SSHHMAC | null = null;
|
||||
private serverSigAlgs: string[] = [];
|
||||
private authRequestSent = false;
|
||||
private pendingChannelRequest: 'pty' | 'shell' | null = null;
|
||||
private ignoreNextKexPacket = false;
|
||||
private inputQueue: Uint8Array[] = [];
|
||||
private queueHeadOffset = 0;
|
||||
private queuedBytes = 0;
|
||||
private flushingInput = false;
|
||||
private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private keepalivePending = 0;
|
||||
private shellTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private readTask: Promise<void> | null = null;
|
||||
private pendingHostConfirmation: { fingerprint: string; resolve: (accepted: boolean) => void } | null = null;
|
||||
|
||||
constructor(ws: WebSocket, socket: Socket, config: SSHConnectionConfig) {
|
||||
this.ws = ws;
|
||||
this.socket = socket;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.status('version_exchange', 'Exchanging SSH protocol versions');
|
||||
await this.write(this.transport.getLocalIdentification());
|
||||
this.readTask = this.readLoop();
|
||||
}
|
||||
|
||||
async handleClientMessage(message: string | ArrayBuffer): Promise<void> {
|
||||
if (this.phase === 'closed') return;
|
||||
if (message instanceof ArrayBuffer) {
|
||||
if (message.byteLength > 64 * 1024) throw new Error('Binary terminal input exceeds 64 KiB');
|
||||
this.queueInput(new Uint8Array(message));
|
||||
return;
|
||||
}
|
||||
|
||||
let frame: unknown;
|
||||
try { frame = JSON.parse(message); } catch { throw new Error('Invalid WebSocket control JSON'); }
|
||||
if (!frame || typeof frame !== 'object' || Array.isArray(frame)) throw new Error('Invalid WebSocket control message');
|
||||
const value = frame as Record<string, unknown>;
|
||||
if (value.type === 'host_key_decision') {
|
||||
const pending = this.pendingHostConfirmation;
|
||||
if (!pending || typeof value.accept !== 'boolean' || value.fingerprint !== pending.fingerprint) throw new Error('Invalid host key decision');
|
||||
this.pendingHostConfirmation = null;
|
||||
pending.resolve(value.accept);
|
||||
return;
|
||||
}
|
||||
if (value.type === 'ping') {
|
||||
this.sendJson({ type: 'pong' });
|
||||
return;
|
||||
}
|
||||
const resize = value.type === 'resize'
|
||||
? [value.cols, value.rows]
|
||||
: Array.isArray(value.resize) ? value.resize : null;
|
||||
if (resize) {
|
||||
await this.resize(resize[0], resize[1]);
|
||||
return;
|
||||
}
|
||||
if (value.type === 'input' || (value.type === undefined && typeof value.data === 'string')) {
|
||||
if (typeof value.data !== 'string' || value.data.length > 256 * 1024) throw new Error('Invalid terminal input');
|
||||
this.queueInput(this.encoder.encode(value.data));
|
||||
return;
|
||||
}
|
||||
throw new Error('Unsupported WebSocket message');
|
||||
}
|
||||
|
||||
close(normal = false): void {
|
||||
if (this.phase === 'closed') return;
|
||||
this.phase = 'closed';
|
||||
if (this.keepaliveTimer) clearInterval(this.keepaliveTimer);
|
||||
if (this.shellTimer) clearTimeout(this.shellTimer);
|
||||
this.keepaliveTimer = null;
|
||||
this.shellTimer = null;
|
||||
this.inputQueue = [];
|
||||
this.queuedBytes = 0;
|
||||
this.pendingHostConfirmation?.resolve(false);
|
||||
this.pendingHostConfirmation = null;
|
||||
try { this.writer?.releaseLock(); } catch { /* already released */ }
|
||||
this.writer = null;
|
||||
try { this.socket.close(); } catch { /* already closed */ }
|
||||
try { this.ws.close(normal ? 1000 : 1011, normal ? 'Session closed' : 'SSH session failed'); } catch { /* already closed */ }
|
||||
}
|
||||
|
||||
private async readLoop(): Promise<void> {
|
||||
const reader = this.socket.readable.getReader();
|
||||
try {
|
||||
while (this.phase !== 'closed') {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
this.status('remote_closed', 'The SSH server closed the connection');
|
||||
this.close(true);
|
||||
return;
|
||||
}
|
||||
if (value.length > 0) await this.consume(value);
|
||||
}
|
||||
} catch (error) {
|
||||
this.fail(error, 'read_error');
|
||||
} finally {
|
||||
try { reader.releaseLock(); } catch { /* ignored */ }
|
||||
}
|
||||
}
|
||||
|
||||
private async consume(data: Uint8Array): Promise<void> {
|
||||
if (this.phase === 'version') {
|
||||
const merged = new Uint8Array(this.versionBuffer.length + data.length);
|
||||
merged.set(this.versionBuffer);
|
||||
merged.set(data, this.versionBuffer.length);
|
||||
if (merged.length > MAX_VERSION_BYTES) throw new Error('SSH identification banner is too large');
|
||||
this.versionBuffer = merged;
|
||||
let start = 0;
|
||||
while (true) {
|
||||
const newline = this.versionBuffer.indexOf(0x0a, start);
|
||||
if (newline < 0) return;
|
||||
const bytes = this.versionBuffer.subarray(start, newline + 1);
|
||||
start = newline + 1;
|
||||
const line = this.decoder.decode(bytes).replace(/\r?\n$/, '');
|
||||
if (!line.startsWith('SSH-')) continue;
|
||||
if (!isSSH2Identification(line)) throw new Error('The server does not support SSH 2.0');
|
||||
this.transport.handleVersionExchange(`${line}\r\n`);
|
||||
const remaining = this.versionBuffer.subarray(start);
|
||||
this.versionBuffer = new Uint8Array();
|
||||
this.phase = 'kex';
|
||||
this.status('version_ready', 'Version exchange complete; negotiating keys');
|
||||
await this.startKex();
|
||||
if (remaining.length > 0) {
|
||||
this.parser.feed(remaining);
|
||||
await this.processPackets();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.parser.feed(data);
|
||||
await this.processPackets();
|
||||
}
|
||||
|
||||
private async startKex(): Promise<void> {
|
||||
this.localKex = KEXInitBuilder.build();
|
||||
await this.sendPlain(this.localKex);
|
||||
}
|
||||
|
||||
private async processPackets(): Promise<void> {
|
||||
while (this.phase !== 'closed') {
|
||||
// NEWKEYS may enable inbound encryption while this same TCP chunk still
|
||||
// contains encrypted packets, so recompute framing on every iteration.
|
||||
const spec = this.decryptor ? getCipherSpec(this.cipherS2C) : null;
|
||||
const macLength = this.decryptor && !spec?.aead ? getMacSpec(this.macS2C).length : 0;
|
||||
const packet = await this.parser.nextPacket(
|
||||
spec?.blockSize ?? 8,
|
||||
this.decryptor ? (data, sequence, aad, commit) => this.decryptor!.decrypt(data, sequence, aad, commit) : (data) => data,
|
||||
Boolean(spec?.aead),
|
||||
macLength,
|
||||
this.verifier ? (data, mac, sequence) => this.verifier!.verify(data, sequence, mac) : undefined,
|
||||
);
|
||||
if (!packet) return;
|
||||
await this.handlePacket(packet);
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePacket(packet: SSHPacket): Promise<void> {
|
||||
const type = packet.payload[0];
|
||||
if (type === SSH_MSG_DISCONNECT) {
|
||||
this.validateDisconnect(packet.payload);
|
||||
this.status('remote_closed', 'The SSH server closed the connection');
|
||||
this.close(true);
|
||||
return;
|
||||
}
|
||||
if (type === SSH_MSG_IGNORE) {
|
||||
const data = this.readBytes(packet.payload, 1);
|
||||
if (data.next !== packet.payload.length) throw new Error('Malformed SSH ignore message');
|
||||
return;
|
||||
}
|
||||
if (type === SSH_MSG_DEBUG) {
|
||||
this.validateDebug(packet.payload);
|
||||
return;
|
||||
}
|
||||
if (type === SSH_MSG_UNIMPLEMENTED) {
|
||||
if (packet.payload.length !== 5) throw new Error('Malformed SSH unimplemented message');
|
||||
return;
|
||||
}
|
||||
if (type === SSH_MSG_GLOBAL_REQUEST) {
|
||||
await this.handleGlobalRequest(packet.payload);
|
||||
return;
|
||||
}
|
||||
if (type === SSH_MSG_REQUEST_SUCCESS || type === SSH_MSG_REQUEST_FAILURE) {
|
||||
this.keepalivePending = 0;
|
||||
return;
|
||||
}
|
||||
if (type === SSH_MSG_KEXINIT && this.phase !== 'kex' && this.phase !== 'host-confirm') throw new Error('Server-initiated SSH rekey is not supported');
|
||||
if (this.phase === 'kex' || this.phase === 'host-confirm') await this.handleKex(type, packet.payload);
|
||||
else if (this.phase === 'auth') await this.handleAuth(type, packet.payload);
|
||||
else if (this.phase === 'pty' || this.phase === 'shell' || this.phase === 'ready') await this.handleChannel(type, packet.payload);
|
||||
}
|
||||
|
||||
private async handleKex(type: number, payload: Uint8Array): Promise<void> {
|
||||
if (this.ignoreNextKexPacket) {
|
||||
this.ignoreNextKexPacket = false;
|
||||
return;
|
||||
}
|
||||
if (type === SSH_MSG_KEXINIT) {
|
||||
if (this.encryptor) throw new Error('SSH rekey is not supported by this terminal session');
|
||||
this.remoteKex = payload;
|
||||
const client = parseKEXInit(this.localKex!);
|
||||
const server = parseKEXInit(payload);
|
||||
this.kexName = negotiate(filterExtInfo(client.kexAlgorithms), filterExtInfo(server.kexAlgorithms), 'key exchange algorithm');
|
||||
this.hostKeyAlgorithm = negotiate(client.hostKeyAlgorithms, server.hostKeyAlgorithms, 'host key algorithm');
|
||||
this.ignoreNextKexPacket = server.firstKexPacketFollows
|
||||
&& (filterExtInfo(server.kexAlgorithms)[0] !== this.kexName || server.hostKeyAlgorithms[0] !== this.hostKeyAlgorithm);
|
||||
this.cipherC2S = negotiate(client.encryptionC2S, server.encryptionC2S, 'client cipher');
|
||||
this.cipherS2C = negotiate(client.encryptionS2C, server.encryptionS2C, 'server cipher');
|
||||
// RFC 4253 negotiates MAC lists even when the chosen AEAD cipher does
|
||||
// not use the result on the wire.
|
||||
const negotiatedMacC2S = negotiate(client.macC2S, server.macC2S, 'client MAC');
|
||||
const negotiatedMacS2C = negotiate(client.macS2C, server.macS2C, 'server MAC');
|
||||
this.macC2S = getCipherSpec(this.cipherC2S).aead ? 'none' : negotiatedMacC2S;
|
||||
this.macS2C = getCipherSpec(this.cipherS2C).aead ? 'none' : negotiatedMacS2C;
|
||||
if (negotiate(client.compressionC2S, server.compressionC2S, 'client compression') !== 'none'
|
||||
|| negotiate(client.compressionS2C, server.compressionS2C, 'server compression') !== 'none') {
|
||||
throw new Error('SSH compression is not supported');
|
||||
}
|
||||
await this.sendEcdhInit();
|
||||
return;
|
||||
}
|
||||
if (type === SSH_MSG_KEX_ECDH_REPLY) {
|
||||
await this.handleEcdhReply(payload);
|
||||
return;
|
||||
}
|
||||
if (type === SSH_MSG_NEWKEYS) {
|
||||
if (!this.keys || !this.encryptor) throw new Error('Received NEWKEYS before completing key exchange');
|
||||
// The server's new keys apply only to subsequent inbound packets.
|
||||
await this.enableInboundEncryption();
|
||||
this.phase = 'auth';
|
||||
this.status('authenticating', 'Encrypted transport established; authenticating');
|
||||
await this.sendEncrypted(new Uint8Array([SSH_MSG_SERVICE_REQUEST, ...encodeString('ssh-userauth')]));
|
||||
}
|
||||
}
|
||||
|
||||
private async sendEcdhInit(): Promise<void> {
|
||||
if (this.kexName && isCurve25519KEXAlgorithm(this.kexName)) {
|
||||
this.curvePair = await Curve25519KeyExchange.generateKeyPair();
|
||||
this.clientPublic = await Curve25519KeyExchange.exportRawPublicKey(this.curvePair);
|
||||
await this.sendPlain(Curve25519KeyExchange.buildInit(this.clientPublic));
|
||||
return;
|
||||
}
|
||||
if (this.kexName === KEX_ALGORITHM_ECDH_NISTP256) {
|
||||
this.ecdhPair = await ECDHKeyExchange.generateKeyPair();
|
||||
this.clientPublic = await ECDHKeyExchange.exportRawPublicKey(this.ecdhPair);
|
||||
await this.sendPlain(ECDHKeyExchange.buildInit(this.clientPublic));
|
||||
return;
|
||||
}
|
||||
throw new Error(`Unsupported key exchange algorithm: ${this.kexName ?? 'none'}`);
|
||||
}
|
||||
|
||||
private async handleEcdhReply(payload: Uint8Array): Promise<void> {
|
||||
if (!this.kexName || !this.clientPublic || !this.localKex || !this.remoteKex) throw new Error('Unexpected key exchange reply');
|
||||
const { hostKey, serverRawPublicKey, signature } = ECDHKeyExchange.parseReply(payload);
|
||||
let sharedSecret: Uint8Array;
|
||||
let hash: Uint8Array;
|
||||
if (isCurve25519KEXAlgorithm(this.kexName)) {
|
||||
if (!this.curvePair) throw new Error('Missing Curve25519 key');
|
||||
sharedSecret = await Curve25519KeyExchange.computeSharedSecret(this.curvePair.privateKey, serverRawPublicKey);
|
||||
hash = await Curve25519KeyExchange.computeExchangeHash(this.transport.getLocalVersion(), this.transport.getRemoteVersion(), this.localKex, this.remoteKex, hostKey, this.clientPublic, serverRawPublicKey, sharedSecret);
|
||||
} else {
|
||||
if (!this.ecdhPair) throw new Error('Missing ECDH key');
|
||||
sharedSecret = await ECDHKeyExchange.computeSharedSecret(this.ecdhPair.privateKey, serverRawPublicKey);
|
||||
hash = await ECDHKeyExchange.computeExchangeHash(this.transport.getLocalVersion(), this.transport.getRemoteVersion(), this.localKex, this.remoteKex, hostKey, this.clientPublic, serverRawPublicKey, sharedSecret);
|
||||
}
|
||||
const keyTypeLength = readUint32(hostKey, 0);
|
||||
if (keyTypeLength > 128 || keyTypeLength + 4 > hostKey.length) throw new Error('Malformed SSH host key');
|
||||
const keyType = this.decoder.decode(hostKey.subarray(4, 4 + keyTypeLength));
|
||||
if (!this.isHostKeyAlgorithmCompatible(this.hostKeyAlgorithm, keyType)) throw new Error(`Server used ${keyType}, but negotiated ${this.hostKeyAlgorithm ?? 'no host key algorithm'}`);
|
||||
const fingerprint = `SHA256:${this.base64(new Uint8Array(await crypto.subtle.digest('SHA-256', toBufferSource(hostKey))))}`;
|
||||
if (this.config.expectedFingerprint && this.config.expectedFingerprint !== fingerprint) throw new Error(`Host key mismatch: expected ${this.config.expectedFingerprint}, received ${fingerprint}`);
|
||||
if (!await this.verifyHostSignature(hostKey, signature, hash)) throw new Error('SSH host key signature verification failed');
|
||||
if (this.config.expectedFingerprint) {
|
||||
this.sendJson({ type: 'host_key', fingerprint, keyType, trusted: true });
|
||||
} else if (!await this.confirmHostKey(fingerprint, keyType)) {
|
||||
throw new Error('Host key was not accepted');
|
||||
}
|
||||
this.status('host_key_verified', `Host key verified (${keyType})`);
|
||||
|
||||
this.sessionId ??= hash;
|
||||
const c2s = getCipherSpec(this.cipherC2S);
|
||||
const s2c = getCipherSpec(this.cipherS2C);
|
||||
this.keys = await KeyDerivation.deriveKeys(
|
||||
sharedSecret,
|
||||
hash,
|
||||
this.sessionId,
|
||||
c2s.ivLength,
|
||||
s2c.ivLength,
|
||||
getMacSpec(this.macC2S).keyLength,
|
||||
getMacSpec(this.macS2C).keyLength,
|
||||
c2s.keyLength,
|
||||
s2c.keyLength,
|
||||
);
|
||||
// Each direction switches independently at its NEWKEYS boundary. Sending
|
||||
// ours now also works with servers that wait before sending their NEWKEYS.
|
||||
await this.sendPlain(new Uint8Array([SSH_MSG_NEWKEYS]));
|
||||
await this.enableOutboundEncryption();
|
||||
}
|
||||
|
||||
private async confirmHostKey(fingerprint: string, keyType: string): Promise<boolean> {
|
||||
this.phase = 'host-confirm';
|
||||
this.status('host_key_confirmation', 'Confirm this host key before credentials are sent');
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (accepted: boolean): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
if (this.pendingHostConfirmation?.fingerprint === fingerprint) this.pendingHostConfirmation = null;
|
||||
if (accepted && this.phase !== 'closed') this.phase = 'kex';
|
||||
resolve(accepted);
|
||||
};
|
||||
const timeout = setTimeout(() => finish(false), 30_000);
|
||||
this.pendingHostConfirmation = { fingerprint, resolve: finish };
|
||||
// Register the decision handler before notifying the browser.
|
||||
this.sendJson({ type: 'host_key', fingerprint, keyType, trusted: false });
|
||||
});
|
||||
}
|
||||
|
||||
private async verifyHostSignature(hostKey: Uint8Array, signature: Uint8Array, hash: Uint8Array): Promise<boolean> {
|
||||
let offset = 0;
|
||||
const keyType = this.readString(hostKey, offset); offset = keyType.next;
|
||||
let sigOffset = 0;
|
||||
const signatureType = this.readString(signature, sigOffset); sigOffset = signatureType.next;
|
||||
const signatureField = this.readBytes(signature, sigOffset);
|
||||
if (signatureField.next !== signature.length) throw new Error('Malformed SSH host signature');
|
||||
const rawSignature = signatureField.value;
|
||||
if (keyType.value === 'ssh-ed25519') {
|
||||
if (this.hostKeyAlgorithm !== 'ssh-ed25519' || signatureType.value !== 'ssh-ed25519') throw new Error('Ed25519 host key or signature does not match the negotiated algorithm');
|
||||
const keyField = this.readBytes(hostKey, offset);
|
||||
if (keyField.next !== hostKey.length || keyField.value.length !== 32 || rawSignature.length !== 64) throw new Error('Malformed Ed25519 host key or signature');
|
||||
const rawKey = keyField.value;
|
||||
const key = await crypto.subtle.importKey('raw', toBufferSource(rawKey), { name: 'Ed25519' }, false, ['verify']);
|
||||
return crypto.subtle.verify('Ed25519', key, toBufferSource(rawSignature), toBufferSource(hash));
|
||||
}
|
||||
if (keyType.value.startsWith('ecdsa-sha2-nistp')) {
|
||||
if (this.hostKeyAlgorithm !== keyType.value || signatureType.value !== keyType.value) throw new Error('ECDSA host key or signature does not match the negotiated algorithm');
|
||||
const curveName = this.readString(hostKey, offset); offset = curveName.next;
|
||||
const keyField = this.readBytes(hostKey, offset);
|
||||
if (keyField.next !== hostKey.length) throw new Error('Malformed ECDSA host key');
|
||||
const rawKey = keyField.value;
|
||||
const curve = keyType.value.endsWith('256') ? 'P-256' : keyType.value.endsWith('384') ? 'P-384' : 'P-521';
|
||||
if (curveName.value !== keyType.value.replace('ecdsa-sha2-', '')) throw new Error('ECDSA curve name does not match the host key type');
|
||||
const digest = curve === 'P-256' ? 'SHA-256' : curve === 'P-384' ? 'SHA-384' : 'SHA-512';
|
||||
const coordinateBytes = curve === 'P-256' ? 32 : curve === 'P-384' ? 48 : 66;
|
||||
const key = await crypto.subtle.importKey('raw', toBufferSource(rawKey), { name: 'ECDSA', namedCurve: curve }, false, ['verify']);
|
||||
return crypto.subtle.verify({ name: 'ECDSA', hash: digest }, key, toBufferSource(this.sshEcdsaToRaw(rawSignature, coordinateBytes)), toBufferSource(hash));
|
||||
}
|
||||
if (keyType.value === 'ssh-rsa') {
|
||||
if (signatureType.value !== 'rsa-sha2-256' && signatureType.value !== 'rsa-sha2-512') throw new Error(`Unsupported RSA host signature type: ${signatureType.value}`);
|
||||
if (signatureType.value !== this.hostKeyAlgorithm) throw new Error(`RSA signature ${signatureType.value} does not match negotiated ${this.hostKeyAlgorithm}`);
|
||||
const exponent = this.readBytes(hostKey, offset); offset = exponent.next;
|
||||
const modulusField = this.readBytes(hostKey, offset);
|
||||
if (modulusField.next !== hostKey.length || exponent.value.length === 0 || modulusField.value.length === 0) throw new Error('Malformed RSA host key');
|
||||
const modulus = modulusField.value;
|
||||
const digest = signatureType.value === 'rsa-sha2-512' ? 'SHA-512' : 'SHA-256';
|
||||
const key = await crypto.subtle.importKey('jwk', { kty: 'RSA', e: this.base64UrlUnsigned(exponent.value), n: this.base64UrlUnsigned(modulus), ext: true }, { name: 'RSASSA-PKCS1-v1_5', hash: digest }, false, ['verify']);
|
||||
return crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, toBufferSource(rawSignature), toBufferSource(hash));
|
||||
}
|
||||
throw new Error(`Unsupported host key type: ${keyType.value}`);
|
||||
}
|
||||
|
||||
private async enableOutboundEncryption(): Promise<void> {
|
||||
if (!this.keys) throw new Error('Session keys are not available');
|
||||
const c2s = getCipherSpec(this.cipherC2S);
|
||||
this.encryptor = c2s.mode === 'gcm'
|
||||
? new SSHAESGCMCipher(this.keys.encKeyClientToServer.subarray(0, c2s.keyLength), this.keys.ivClientToServer)
|
||||
: new SSHAESCTRCipher(this.keys.encKeyClientToServer.subarray(0, c2s.keyLength), this.keys.ivClientToServer);
|
||||
await this.encryptor.init();
|
||||
if (!c2s.aead) { this.signer = new SSHHMAC(this.macC2S, this.keys.integrityKeyC2S); await this.signer.init(); }
|
||||
}
|
||||
|
||||
private async enableInboundEncryption(): Promise<void> {
|
||||
if (!this.keys) throw new Error('Session keys are not available');
|
||||
const s2c = getCipherSpec(this.cipherS2C);
|
||||
this.decryptor = s2c.mode === 'gcm'
|
||||
? new SSHAESGCMCipher(this.keys.encKeyServerToClient.subarray(0, s2c.keyLength), this.keys.ivServerToClient)
|
||||
: new SSHAESCTRCipher(this.keys.encKeyServerToClient.subarray(0, s2c.keyLength), this.keys.ivServerToClient);
|
||||
await this.decryptor.init();
|
||||
if (!s2c.aead) { this.verifier = new SSHHMAC(this.macS2C, this.keys.integrityKeyS2C); await this.verifier.init(); }
|
||||
}
|
||||
|
||||
private async handleAuth(type: number, payload: Uint8Array): Promise<void> {
|
||||
if (type === SSH_MSG_EXT_INFO) {
|
||||
if (this.authRequestSent) throw new Error('Unexpected SSH extension information after authentication started');
|
||||
this.serverSigAlgs = parseServerSigAlgs(payload);
|
||||
return;
|
||||
}
|
||||
if (type === SSH_MSG_SERVICE_ACCEPT) {
|
||||
const service = this.readString(payload, 1);
|
||||
if (service.next !== payload.length || service.value !== 'ssh-userauth') throw new Error('Invalid SSH user authentication service acceptance');
|
||||
if (this.authRequestSent) throw new Error('Duplicate SSH user authentication service acceptance');
|
||||
const request = this.config.authMethod === 'publickey'
|
||||
? await SSHAuth.buildPublicKeyAuthRequest(this.config.username, this.config.privateKey!, this.sessionId!, this.serverSigAlgs, false)
|
||||
: SSHAuth.buildPasswordAuthRequest(this.config.username, this.config.password!);
|
||||
this.authRequestSent = true;
|
||||
this.config.password = undefined;
|
||||
this.config.privateKey = undefined;
|
||||
await this.sendEncrypted(request);
|
||||
return;
|
||||
}
|
||||
if (type === SSH_MSG_USERAUTH_SUCCESS) {
|
||||
if (payload.length !== 1) throw new Error('Malformed SSH authentication success');
|
||||
if (!this.authRequestSent) throw new Error('SSH authentication completed before credentials were sent');
|
||||
this.status('auth_success', 'SSH authentication succeeded; opening terminal');
|
||||
this.phase = 'pty';
|
||||
this.startKeepalive();
|
||||
await this.sendEncrypted(this.channel.buildOpenSession(0));
|
||||
return;
|
||||
}
|
||||
if (type === SSH_MSG_USERAUTH_FAILURE) {
|
||||
if (!this.authRequestSent) throw new Error('SSH authentication failed before credentials were sent');
|
||||
this.validateAuthFailure(payload);
|
||||
throw new Error('SSH authentication failed');
|
||||
}
|
||||
}
|
||||
|
||||
private async handleChannel(type: number, payload: Uint8Array): Promise<void> {
|
||||
if (type === SSH_MSG_CHANNEL_OPEN_CONFIRMATION) {
|
||||
if (this.phase !== 'pty') throw new Error('Unexpected channel open confirmation');
|
||||
this.channel.handleOpenConfirmation(payload);
|
||||
this.pendingChannelRequest = 'pty';
|
||||
await this.sendEncrypted(this.channel.buildPTYRequest(this.config.cols, this.config.rows, this.config.term));
|
||||
return;
|
||||
}
|
||||
if (type === SSH_MSG_CHANNEL_OPEN_FAILURE) {
|
||||
this.channel.handleOpenFailure(payload);
|
||||
throw new Error('SSH server rejected the session channel');
|
||||
}
|
||||
if (type === SSH_MSG_CHANNEL_SUCCESS || type === SSH_MSG_CHANNEL_FAILURE) {
|
||||
this.channel.handleRequestResult(payload);
|
||||
const pending = this.pendingChannelRequest;
|
||||
if (!pending || pending !== this.phase) throw new Error('Unexpected SSH channel request result');
|
||||
this.pendingChannelRequest = null;
|
||||
if (type === SSH_MSG_CHANNEL_FAILURE) throw new Error(`SSH server rejected the ${pending === 'pty' ? 'PTY' : 'shell'} request`);
|
||||
if (pending === 'pty') {
|
||||
this.phase = 'shell';
|
||||
this.pendingChannelRequest = 'shell';
|
||||
await this.sendEncrypted(this.channel.buildShellRequest());
|
||||
this.shellTimer = setTimeout(() => this.markReady(), 3000);
|
||||
} else this.markReady();
|
||||
return;
|
||||
}
|
||||
if (type === SSH_MSG_CHANNEL_DATA) {
|
||||
if (this.phase === 'shell') this.markReady();
|
||||
const output = this.channel.handleChannelData(payload);
|
||||
this.ws.send(output);
|
||||
await this.adjustLocalWindow();
|
||||
return;
|
||||
}
|
||||
if (type === SSH_MSG_CHANNEL_EXTENDED_DATA) {
|
||||
if (this.phase === 'shell') this.markReady();
|
||||
const output = this.channel.handleExtendedData(payload);
|
||||
this.ws.send(output);
|
||||
await this.adjustLocalWindow();
|
||||
return;
|
||||
}
|
||||
if (type === SSH_MSG_CHANNEL_WINDOW_ADJUST) {
|
||||
this.channel.handleWindowAdjust(payload);
|
||||
void this.flushInput();
|
||||
return;
|
||||
}
|
||||
if (type === SSH_MSG_CHANNEL_EOF) {
|
||||
this.channel.handleEof(payload);
|
||||
this.status('remote_eof', 'SSH server finished sending output');
|
||||
return;
|
||||
}
|
||||
if (type === SSH_MSG_CHANNEL_CLOSE) {
|
||||
this.channel.handleClose(payload);
|
||||
if (!this.channel.hasSentClose()) await this.sendEncrypted(this.channel.buildClose());
|
||||
this.status('session_ended', 'SSH session ended');
|
||||
this.close(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private markReady(): void {
|
||||
if (this.phase === 'ready' || this.phase === 'closed') return;
|
||||
if (this.shellTimer) clearTimeout(this.shellTimer);
|
||||
this.shellTimer = null;
|
||||
this.pendingChannelRequest = null;
|
||||
this.phase = 'ready';
|
||||
this.sendJson({
|
||||
type: 'ready',
|
||||
negotiated: { kex: this.kexName, cipherC2S: this.cipherC2S, cipherS2C: this.cipherS2C, macC2S: this.macC2S, macS2C: this.macS2C },
|
||||
});
|
||||
this.status('shell_ready', 'Shell is ready');
|
||||
void this.flushInput();
|
||||
}
|
||||
|
||||
private queueInput(data: Uint8Array): void {
|
||||
if (data.length === 0) return;
|
||||
if (this.phase !== 'ready') throw new Error('Terminal is not ready');
|
||||
if (this.queuedBytes + data.length > MAX_QUEUED_INPUT) throw new Error('Terminal input queue limit exceeded');
|
||||
this.inputQueue.push(data);
|
||||
this.queuedBytes += data.length;
|
||||
void this.flushInput();
|
||||
}
|
||||
|
||||
private async flushInput(): Promise<void> {
|
||||
if (this.flushingInput || this.phase !== 'ready') return;
|
||||
this.flushingInput = true;
|
||||
try {
|
||||
while (this.inputQueue.length > 0 && this.phase === 'ready') {
|
||||
const first = this.inputQueue[0];
|
||||
const chunk = this.channel.takeChannelDataChunk(first, this.queueHeadOffset);
|
||||
if (!chunk) return;
|
||||
await this.sendChannelData(chunk);
|
||||
this.queueHeadOffset += chunk.bytesConsumed;
|
||||
this.queuedBytes -= chunk.bytesConsumed;
|
||||
if (this.queueHeadOffset === first.length) {
|
||||
this.inputQueue.shift();
|
||||
this.queueHeadOffset = 0;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.flushingInput = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async resize(cols: unknown, rows: unknown): Promise<void> {
|
||||
if (typeof cols !== 'number' || typeof rows !== 'number' || !Number.isInteger(cols) || !Number.isInteger(rows) || cols < 10 || cols > 1000 || rows < 5 || rows > 1000) throw new Error('Invalid terminal size');
|
||||
if (this.phase === 'ready') await this.sendEncrypted(this.channel.buildWindowChange(cols, rows));
|
||||
}
|
||||
|
||||
private async adjustLocalWindow(): Promise<void> {
|
||||
const amount = this.channel.takeLocalWindowAdjustment(LOCAL_WINDOW_THRESHOLD);
|
||||
if (amount !== null) await this.sendEncrypted(this.channel.buildWindowAdjust(amount));
|
||||
}
|
||||
|
||||
private async handleGlobalRequest(payload: Uint8Array): Promise<void> {
|
||||
if (payload.length < 6) throw new Error('Malformed global request');
|
||||
const nameLength = readUint32(payload, 1);
|
||||
if (5 + nameLength >= payload.length) throw new Error('Malformed global request');
|
||||
try { new TextDecoder('utf-8', { fatal: true }).decode(payload.subarray(5, 5 + nameLength)); }
|
||||
catch { throw new Error('Malformed global request name'); }
|
||||
if (payload[5 + nameLength] > 1) throw new Error('Malformed global request reply flag');
|
||||
const wantsReply = payload[5 + nameLength] === 1;
|
||||
if (wantsReply && this.encryptor) await this.sendEncrypted(new Uint8Array([SSH_MSG_REQUEST_FAILURE]));
|
||||
}
|
||||
|
||||
private startKeepalive(): void {
|
||||
this.keepaliveTimer = setInterval(() => {
|
||||
if (this.phase === 'closed') return;
|
||||
if (this.keepalivePending >= 3) {
|
||||
this.fail(new Error('SSH keepalive timed out'), 'keepalive_timeout');
|
||||
return;
|
||||
}
|
||||
this.keepalivePending++;
|
||||
const payload = new Uint8Array(1 + 4 + KEEPALIVE_NAME.length + 1);
|
||||
payload[0] = SSH_MSG_GLOBAL_REQUEST;
|
||||
new DataView(payload.buffer).setUint32(1, KEEPALIVE_NAME.length, false);
|
||||
payload.set(KEEPALIVE_NAME, 5);
|
||||
payload[payload.length - 1] = 1;
|
||||
void this.sendEncrypted(payload).catch((error) => this.fail(error, 'keepalive_failed'));
|
||||
}, 25_000);
|
||||
}
|
||||
|
||||
private async sendPlain(payload: Uint8Array): Promise<void> {
|
||||
await this.serialSend(async () => {
|
||||
const packet = await SSHPacketBuilder.build(payload, 8, null, this.sendSequence);
|
||||
this.sendSequence = nextSequenceNumber(this.sendSequence);
|
||||
await this.write(packet);
|
||||
});
|
||||
}
|
||||
|
||||
private async sendEncrypted(payload: Uint8Array): Promise<void> {
|
||||
await this.serialSend(async () => {
|
||||
if (!this.encryptor) throw new Error('SSH encryption is not initialized');
|
||||
const spec = getCipherSpec(this.cipherC2S);
|
||||
const packet = await SSHPacketBuilder.build(payload, spec.blockSize, (data, sequence, aad) => this.encryptor!.encrypt(data, sequence, aad), this.sendSequence, spec.aead, this.signer ? (data, sequence) => this.signer!.sign(data, sequence) : undefined);
|
||||
this.sendSequence = nextSequenceNumber(this.sendSequence);
|
||||
await this.write(packet);
|
||||
});
|
||||
}
|
||||
|
||||
private async sendChannelData(chunk: ChannelDataChunk): Promise<void> {
|
||||
await this.serialSend(async () => {
|
||||
if (!this.encryptor) throw new Error('SSH encryption is not initialized');
|
||||
const spec = getCipherSpec(this.cipherC2S);
|
||||
const packet = await SSHPacketBuilder.buildWithPayloadWriter(chunk.payloadLength, (target, offset) => this.channel.writeChannelDataPayload(target, offset, chunk.source, chunk.sourceOffset, chunk.bytesConsumed), spec.blockSize, (data, sequence, aad) => this.encryptor!.encrypt(data, sequence, aad), this.sendSequence, spec.aead, this.signer ? (data, sequence) => this.signer!.sign(data, sequence) : undefined);
|
||||
this.sendSequence = nextSequenceNumber(this.sendSequence);
|
||||
await this.write(packet);
|
||||
});
|
||||
}
|
||||
|
||||
private async serialSend(operation: () => Promise<void>): Promise<void> {
|
||||
const next = this.sendChain.then(operation);
|
||||
this.sendChain = next.catch(() => undefined);
|
||||
await next;
|
||||
}
|
||||
|
||||
private async write(data: Uint8Array): Promise<void> {
|
||||
const writer = this.writer ?? this.socket.writable.getWriter();
|
||||
this.writer = writer;
|
||||
await writer.write(data);
|
||||
}
|
||||
|
||||
private readBytes(bytes: Uint8Array, offset: number): { value: Uint8Array; next: number } {
|
||||
if (offset + 4 > bytes.length) throw new Error('Malformed SSH key data');
|
||||
const length = readUint32(bytes, offset);
|
||||
if (length > bytes.length - offset - 4) throw new Error('Malformed SSH key data');
|
||||
return { value: bytes.subarray(offset + 4, offset + 4 + length), next: offset + 4 + length };
|
||||
}
|
||||
|
||||
private readString(bytes: Uint8Array, offset: number): { value: string; next: number } {
|
||||
const field = this.readBytes(bytes, offset);
|
||||
return { value: this.decoder.decode(field.value), next: field.next };
|
||||
}
|
||||
|
||||
private sshEcdsaToRaw(signature: Uint8Array, coordinateBytes: number): Uint8Array {
|
||||
const rField = this.readBytes(signature, 0);
|
||||
const sField = this.readBytes(signature, rField.next);
|
||||
if (sField.next !== signature.length) throw new Error('Malformed ECDSA signature trailing data');
|
||||
const trim = (value: Uint8Array): Uint8Array => {
|
||||
if (value.length === 0 || (value[0] & 0x80) !== 0) throw new Error('ECDSA signature integers must be positive');
|
||||
if (value.length > 1 && value[0] === 0 && (value[1] & 0x80) === 0) throw new Error('ECDSA signature integer is not minimally encoded');
|
||||
const normalized = value.length > 1 && value[0] === 0 ? value.subarray(1) : value;
|
||||
if (normalized.every((byte) => byte === 0)) throw new Error('ECDSA signature integers must be non-zero');
|
||||
return normalized;
|
||||
};
|
||||
const r = trim(rField.value); const s = trim(sField.value);
|
||||
if (r.length > coordinateBytes || s.length > coordinateBytes) throw new Error('Invalid ECDSA signature');
|
||||
const result = new Uint8Array(coordinateBytes * 2);
|
||||
result.set(r, coordinateBytes - r.length);
|
||||
result.set(s, coordinateBytes * 2 - s.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
private base64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replace(/=+$/, '');
|
||||
}
|
||||
|
||||
private base64UrlUnsigned(bytes: Uint8Array): string {
|
||||
let start = 0;
|
||||
while (start < bytes.length - 1 && bytes[start] === 0) start++;
|
||||
return this.base64(bytes.subarray(start)).replace(/\+/g, '-').replace(/\//g, '_');
|
||||
}
|
||||
|
||||
private validateDisconnect(payload: Uint8Array): void {
|
||||
if (payload.length < 13) throw new Error('Malformed SSH disconnect message');
|
||||
let offset = 5;
|
||||
offset = this.readBytes(payload, offset).next;
|
||||
offset = this.readBytes(payload, offset).next;
|
||||
if (offset !== payload.length) throw new Error('Malformed SSH disconnect message');
|
||||
}
|
||||
|
||||
private validateDebug(payload: Uint8Array): void {
|
||||
if (payload.length < 10 || payload[1] > 1) throw new Error('Malformed SSH debug message');
|
||||
let offset = 2;
|
||||
offset = this.readBytes(payload, offset).next;
|
||||
offset = this.readBytes(payload, offset).next;
|
||||
if (offset !== payload.length) throw new Error('Malformed SSH debug message');
|
||||
}
|
||||
|
||||
private validateAuthFailure(payload: Uint8Array): void {
|
||||
if (payload.length < 6) throw new Error('Malformed SSH authentication failure');
|
||||
const methodsLength = readUint32(payload, 1);
|
||||
if (methodsLength > payload.length - 6 || methodsLength + 6 !== payload.length) {
|
||||
throw new Error('Malformed SSH authentication failure');
|
||||
}
|
||||
}
|
||||
|
||||
private isHostKeyAlgorithmCompatible(negotiated: string | null, keyType: string): boolean {
|
||||
if (!negotiated) return false;
|
||||
if (negotiated === keyType) return true;
|
||||
return keyType === 'ssh-rsa' && (negotiated === 'rsa-sha2-256' || negotiated === 'rsa-sha2-512');
|
||||
}
|
||||
|
||||
private status(event: string, message: string): void { this.sendJson({ type: 'status', event, message }); }
|
||||
private sendJson(value: unknown): void { if (this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(value)); }
|
||||
private fail(error: unknown, event: string): void {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.sendJson({ type: 'error', event, message });
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export function secureResponse(response: Response): Response {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set('Strict-Transport-Security', 'max-age=31536000');
|
||||
headers.set('X-Content-Type-Options', 'nosniff');
|
||||
headers.set('X-Frame-Options', 'DENY');
|
||||
headers.set('Referrer-Policy', 'same-origin');
|
||||
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
if (response.headers.get('Content-Type')?.includes('text/html')) {
|
||||
headers.set('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'");
|
||||
}
|
||||
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
||||
}
|
||||
|
||||
export function jsonError(error: string, status: number): Response {
|
||||
return secureResponse(Response.json({ error }, { status, headers: { 'Cache-Control': 'no-store' } }));
|
||||
}
|
||||
|
||||
export function isProductionHttp(request: Request): boolean {
|
||||
return new URL(request.url).protocol === 'http:' && request.headers.has('CF-Connecting-IP');
|
||||
}
|
||||
|
||||
export function httpsRedirect(request: Request): Response {
|
||||
const url = new URL(request.url);
|
||||
url.protocol = 'https:';
|
||||
return secureResponse(new Response(null, {
|
||||
status: 308,
|
||||
headers: { Location: url.toString(), 'Cache-Control': 'no-store' },
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
export const KEX_ALGORITHM_CURVE25519_SHA256 = 'curve25519-sha256';
|
||||
export const KEX_ALGORITHM_ECDH_NISTP256 = 'ecdh-sha2-nistp256';
|
||||
|
||||
export const SUPPORTED_KEX_ALGORITHMS = [
|
||||
KEX_ALGORITHM_CURVE25519_SHA256,
|
||||
KEX_ALGORITHM_ECDH_NISTP256,
|
||||
];
|
||||
|
||||
export const SUPPORTED_ENCRYPTION_ALGORITHMS = [
|
||||
'aes128-gcm@openssh.com',
|
||||
'aes256-gcm@openssh.com',
|
||||
'aes128-ctr',
|
||||
'aes192-ctr',
|
||||
'aes256-ctr',
|
||||
];
|
||||
|
||||
export const SUPPORTED_MAC_ALGORITHMS = [
|
||||
'hmac-sha2-256',
|
||||
'hmac-sha2-512',
|
||||
];
|
||||
|
||||
export interface CipherSpec {
|
||||
mode: 'gcm' | 'ctr';
|
||||
blockSize: number;
|
||||
ivLength: number;
|
||||
keyLength: number;
|
||||
aead: boolean;
|
||||
}
|
||||
|
||||
export interface MacSpec {
|
||||
length: number;
|
||||
keyLength: number;
|
||||
}
|
||||
|
||||
const CIPHER_SPECS: Record<string, CipherSpec> = {
|
||||
'aes256-gcm@openssh.com': { mode: 'gcm', blockSize: 16, ivLength: 12, keyLength: 32, aead: true },
|
||||
'aes128-gcm@openssh.com': { mode: 'gcm', blockSize: 16, ivLength: 12, keyLength: 16, aead: true },
|
||||
'aes256-ctr': { mode: 'ctr', blockSize: 16, ivLength: 16, keyLength: 32, aead: false },
|
||||
'aes192-ctr': { mode: 'ctr', blockSize: 16, ivLength: 16, keyLength: 24, aead: false },
|
||||
'aes128-ctr': { mode: 'ctr', blockSize: 16, ivLength: 16, keyLength: 16, aead: false },
|
||||
};
|
||||
|
||||
const MAC_SPECS: Record<string, MacSpec> = {
|
||||
none: { length: 0, keyLength: 32 },
|
||||
'hmac-sha2-256': { length: 32, keyLength: 32 },
|
||||
'hmac-sha2-512': { length: 64, keyLength: 64 },
|
||||
};
|
||||
|
||||
export function isCurve25519KEXAlgorithm(algorithm: string): boolean {
|
||||
return algorithm === KEX_ALGORITHM_CURVE25519_SHA256;
|
||||
}
|
||||
|
||||
export function getCipherSpec(algorithm: string): CipherSpec {
|
||||
const spec = CIPHER_SPECS[algorithm];
|
||||
if (!spec) throw new Error(`Unsupported cipher: ${algorithm}`);
|
||||
return spec;
|
||||
}
|
||||
|
||||
export function getMacSpec(algorithm: string): MacSpec {
|
||||
const spec = MAC_SPECS[algorithm];
|
||||
if (!spec) throw new Error(`Unsupported MAC algorithm: ${algorithm}`);
|
||||
return spec;
|
||||
}
|
||||
|
||||
export function getMacAlgorithmsForCipher(cipher: string): string[] {
|
||||
if (getCipherSpec(cipher).aead) return ['none'];
|
||||
return SUPPORTED_MAC_ALGORITHMS;
|
||||
}
|
||||
+832
@@ -0,0 +1,832 @@
|
||||
import { SSH_MSG_USERAUTH_REQUEST } from '../types';
|
||||
import { encodeString, concat, readUint32, toBufferSource } from './utils';
|
||||
|
||||
// SSH key type constants
|
||||
const SSH_ED25519 = 'ssh-ed25519';
|
||||
const SSH_RSA = 'ssh-rsa';
|
||||
const ECDSA_SHA2_NISTP256 = 'ecdsa-sha2-nistp256';
|
||||
const ECDSA_SHA2_NISTP384 = 'ecdsa-sha2-nistp384';
|
||||
const ECDSA_SHA2_NISTP521 = 'ecdsa-sha2-nistp521';
|
||||
|
||||
// Web Crypto algorithm names
|
||||
const ED25519_ALGO = 'Ed25519';
|
||||
const RSA_ALGO = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
|
||||
const ECDSA_P256_ALGO = { name: 'ECDSA', namedCurve: 'P-256' };
|
||||
const ECDSA_P384_ALGO = { name: 'ECDSA', namedCurve: 'P-384' };
|
||||
const ECDSA_P521_ALGO = { name: 'ECDSA', namedCurve: 'P-521' };
|
||||
|
||||
interface ParsedKey {
|
||||
signingKey: CryptoKey;
|
||||
rsaPkcs8?: Uint8Array;
|
||||
publicKeyBlob: Uint8Array;
|
||||
keyType: string;
|
||||
}
|
||||
|
||||
export class SSHAuth {
|
||||
static buildPasswordAuthRequest(
|
||||
username: string,
|
||||
password: string
|
||||
): Uint8Array {
|
||||
const parts: Uint8Array[] = [
|
||||
new Uint8Array([SSH_MSG_USERAUTH_REQUEST]),
|
||||
encodeString(username),
|
||||
encodeString('ssh-connection'),
|
||||
encodeString('password'),
|
||||
new Uint8Array([0x00]),
|
||||
encodeString(password),
|
||||
];
|
||||
|
||||
return concat(...parts);
|
||||
}
|
||||
|
||||
/** Build a signed public-key authentication request (RFC 4252 section 7). */
|
||||
static async buildPublicKeyAuthRequest(
|
||||
username: string,
|
||||
privateKeyPEM: string,
|
||||
sessionID: Uint8Array,
|
||||
serverSigAlgs?: string[],
|
||||
allowLegacyRsaSha1: boolean = false,
|
||||
): Promise<Uint8Array> {
|
||||
const { signingKey, publicKeyBlob, keyType, rsaPkcs8 } = await this.parsePrivateKey(privateKeyPEM);
|
||||
|
||||
let requestAlgo = keyType;
|
||||
let signatureAlgo = keyType;
|
||||
|
||||
if (keyType === SSH_RSA) {
|
||||
const chosen = this.selectRsaSigAlgorithm(serverSigAlgs, allowLegacyRsaSha1);
|
||||
requestAlgo = chosen;
|
||||
signatureAlgo = chosen;
|
||||
}
|
||||
|
||||
// Build the request body (without signature first)
|
||||
const requestBody = concat(
|
||||
new Uint8Array([SSH_MSG_USERAUTH_REQUEST]),
|
||||
encodeString(username),
|
||||
encodeString('ssh-connection'),
|
||||
encodeString('publickey'),
|
||||
new Uint8Array([0x01]), // TRUE = has signature
|
||||
encodeString(requestAlgo),
|
||||
encodeString(publicKeyBlob),
|
||||
);
|
||||
|
||||
// Data to sign: session_id_string || request_body
|
||||
const dataToSign = concat(encodeString(sessionID), requestBody);
|
||||
|
||||
// Sign based on key type
|
||||
let rawSignature: Uint8Array;
|
||||
let signatureBlob: Uint8Array;
|
||||
|
||||
if (keyType === SSH_ED25519) {
|
||||
rawSignature = new Uint8Array(await crypto.subtle.sign(ED25519_ALGO, signingKey, toBufferSource(dataToSign)));
|
||||
signatureBlob = concat(
|
||||
encodeString(SSH_ED25519),
|
||||
encodeString(rawSignature),
|
||||
);
|
||||
} else if (keyType === SSH_RSA) {
|
||||
const hash = signatureAlgo === 'rsa-sha2-512' ? 'SHA-512'
|
||||
: signatureAlgo === 'ssh-rsa' ? 'SHA-1'
|
||||
: 'SHA-256';
|
||||
let sigKey = signingKey;
|
||||
if (hash !== 'SHA-256' && rsaPkcs8) {
|
||||
sigKey = await crypto.subtle.importKey(
|
||||
'pkcs8', toBufferSource(rsaPkcs8),
|
||||
{ name: 'RSASSA-PKCS1-v1_5', hash },
|
||||
false, ['sign'],
|
||||
);
|
||||
}
|
||||
rawSignature = new Uint8Array(
|
||||
await crypto.subtle.sign({ name: 'RSASSA-PKCS1-v1_5', hash }, sigKey, toBufferSource(dataToSign))
|
||||
);
|
||||
signatureBlob = concat(
|
||||
encodeString(signatureAlgo),
|
||||
encodeString(rawSignature),
|
||||
);
|
||||
} else if (keyType.startsWith('ecdsa-sha2-')) {
|
||||
const hash = this.ecdsaHashForCurve(keyType);
|
||||
const sigBytes = new Uint8Array(await crypto.subtle.sign(
|
||||
{ name: 'ECDSA', hash },
|
||||
signingKey,
|
||||
toBufferSource(dataToSign)
|
||||
));
|
||||
const sshSignature = this.ecdsaWebCryptoToSSH(sigBytes, this.ecdsaCoordBytes(keyType));
|
||||
signatureBlob = concat(
|
||||
encodeString(keyType),
|
||||
encodeString(sshSignature),
|
||||
);
|
||||
} else {
|
||||
throw new Error(`Unsupported key type: ${keyType}`);
|
||||
}
|
||||
|
||||
// Full auth packet: requestBody || string signature_blob
|
||||
return concat(requestBody, encodeString(signatureBlob));
|
||||
}
|
||||
private static selectRsaSigAlgorithm(
|
||||
serverSigAlgs: string[] | undefined,
|
||||
allowLegacyRsaSha1: boolean,
|
||||
): string {
|
||||
const localOrder = allowLegacyRsaSha1
|
||||
? ['rsa-sha2-512', 'rsa-sha2-256', 'ssh-rsa']
|
||||
: ['rsa-sha2-512', 'rsa-sha2-256'];
|
||||
|
||||
if (!serverSigAlgs || serverSigAlgs.length === 0) {
|
||||
return 'rsa-sha2-256';
|
||||
}
|
||||
|
||||
const serverSet = new Set(serverSigAlgs);
|
||||
for (const algo of localOrder) {
|
||||
if (serverSet.has(algo)) return algo;
|
||||
}
|
||||
throw new Error(
|
||||
`no_supported_rsa_signature_algorithm: server=[${serverSigAlgs.join(',')}] local=[${localOrder.join(',')}]`
|
||||
);
|
||||
}
|
||||
private static ecdsaHashForCurve(keyType: string): 'SHA-256' | 'SHA-384' | 'SHA-512' {
|
||||
switch (keyType) {
|
||||
case ECDSA_SHA2_NISTP256: return 'SHA-256';
|
||||
case ECDSA_SHA2_NISTP384: return 'SHA-384';
|
||||
case ECDSA_SHA2_NISTP521: return 'SHA-512';
|
||||
}
|
||||
throw new Error(`unsupported ECDSA key type: ${keyType}`);
|
||||
}
|
||||
private static ecdsaCoordBytes(keyType: string): number {
|
||||
switch (keyType) {
|
||||
case ECDSA_SHA2_NISTP256: return 32;
|
||||
case ECDSA_SHA2_NISTP384: return 48;
|
||||
case ECDSA_SHA2_NISTP521: return 66;
|
||||
}
|
||||
throw new Error(`unsupported ECDSA key type: ${keyType}`);
|
||||
}
|
||||
private static ecdsaWebCryptoToSSH(sigBytes: Uint8Array, coordBytes: number): Uint8Array {
|
||||
if (sigBytes.length < 2) throw new Error('ECDSA signature is too short');
|
||||
|
||||
// Workers Web Crypto returns IEEE-P1363 r || s. Check its exact size
|
||||
// before considering DER because a valid r can itself start with 0x30.
|
||||
if (sigBytes.length === coordBytes * 2) {
|
||||
const r = sigBytes.subarray(0, coordBytes);
|
||||
const s = sigBytes.subarray(coordBytes);
|
||||
return concat(this.sshMPInt(r), this.sshMPInt(s));
|
||||
}
|
||||
if (sigBytes[0] === 0x30) return this.convertECDSADERToSSH(sigBytes);
|
||||
throw new Error(`Invalid ECDSA signature length: expected ${coordBytes * 2}; received ${sigBytes.length}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an OpenSSH private key and detect its type.
|
||||
*/
|
||||
private static async parsePrivateKey(pem: string): Promise<ParsedKey> {
|
||||
const match = /^-----BEGIN OPENSSH PRIVATE KEY-----\r?\n([A-Za-z0-9+/=\r\n]+)\r?\n-----END OPENSSH PRIVATE KEY-----$/.exec(pem.trim());
|
||||
if (!match) throw new Error('Unsupported private key format; only OpenSSH keys are accepted');
|
||||
const b64 = match[1].replace(/\r?\n/g, '');
|
||||
if (b64.length === 0 || b64.length % 4 !== 0) throw new Error('Malformed OpenSSH private key Base64 data');
|
||||
let raw: Uint8Array;
|
||||
try {
|
||||
const binary = atob(b64);
|
||||
raw = Uint8Array.from(binary, c => c.charCodeAt(0));
|
||||
const canonical = btoa(binary);
|
||||
if (canonical !== b64) throw new Error('non-canonical Base64');
|
||||
} catch {
|
||||
throw new Error('Malformed OpenSSH private key Base64 data');
|
||||
}
|
||||
|
||||
// Parse OpenSSH format: "openssh-key-v1\0" magic
|
||||
const magic = 'openssh-key-v1\0';
|
||||
const magicBytes = new TextEncoder().encode(magic);
|
||||
if (raw.length < magicBytes.length) {
|
||||
throw new Error('Private key data is too short');
|
||||
}
|
||||
for (let i = 0; i < magicBytes.length; i++) {
|
||||
if (raw[i] !== magicBytes[i]) {
|
||||
throw new Error('Unsupported private key format; only OpenSSH keys are accepted');
|
||||
}
|
||||
}
|
||||
let offset = magicBytes.length;
|
||||
|
||||
// ciphername
|
||||
if (offset + 4 > raw.length) throw new Error('Malformed private key cipher length');
|
||||
const cipherLen = readUint32(raw, offset); offset += 4;
|
||||
if (offset + cipherLen > raw.length) throw new Error('Malformed private key cipher field');
|
||||
const cipher = this.decodeText(raw.slice(offset, offset + cipherLen), 'private key cipher'); offset += cipherLen;
|
||||
if (cipher !== 'none') throw new Error('Encrypted private keys are not supported; remove the passphrase with ssh-keygen -p');
|
||||
|
||||
// kdfname
|
||||
if (offset + 4 > raw.length) throw new Error('Malformed private key KDF length');
|
||||
const kdfLen = readUint32(raw, offset); offset += 4;
|
||||
if (offset + kdfLen > raw.length) throw new Error('Malformed private key KDF field');
|
||||
const kdf = this.decodeText(raw.slice(offset, offset + kdfLen), 'private key KDF');
|
||||
offset += kdfLen;
|
||||
if (kdf !== 'none') {
|
||||
throw new Error('Malformed unencrypted private key: KDF must be none');
|
||||
}
|
||||
|
||||
// kdfoptions
|
||||
if (offset + 4 > raw.length) throw new Error('Malformed private key KDF options length');
|
||||
const kdfOptLen = readUint32(raw, offset); offset += 4;
|
||||
if (offset + kdfOptLen > raw.length) throw new Error('Malformed private key KDF options');
|
||||
if (kdfOptLen !== 0) {
|
||||
throw new Error('Malformed unencrypted private key: KDF options must be empty');
|
||||
}
|
||||
offset += kdfOptLen;
|
||||
|
||||
// number of keys
|
||||
if (offset + 4 > raw.length) throw new Error('Malformed private key count');
|
||||
const numKeys = readUint32(raw, offset); offset += 4;
|
||||
if (numKeys !== 1) throw new Error('Only single-key files are supported');
|
||||
|
||||
// public key section
|
||||
if (offset + 4 > raw.length) throw new Error('Malformed public key section length');
|
||||
const pubSecLen = readUint32(raw, offset); offset += 4;
|
||||
if (offset + pubSecLen > raw.length) throw new Error('Malformed public key section');
|
||||
const publicKeyBlob = raw.slice(offset, offset + pubSecLen);
|
||||
offset += pubSecLen;
|
||||
|
||||
// private key section
|
||||
if (offset + 4 > raw.length) throw new Error('Malformed private key section length');
|
||||
const privSecLen = readUint32(raw, offset); offset += 4;
|
||||
if (offset + privSecLen > raw.length) throw new Error('Malformed private key section');
|
||||
const privSection = raw.slice(offset, offset + privSecLen);
|
||||
offset += privSecLen;
|
||||
if (offset !== raw.length) throw new Error('Trailing data after private key section');
|
||||
|
||||
// Parse private section: checkint1, checkint2, keytype, ...
|
||||
let po = 0;
|
||||
if (privSection.length < 8) throw new Error('Malformed private key check integers');
|
||||
const checkint1 = readUint32(privSection, po); po += 4;
|
||||
const checkint2 = readUint32(privSection, po); po += 4;
|
||||
if (checkint1 !== checkint2) {
|
||||
throw new Error('Private key check integers do not match');
|
||||
}
|
||||
|
||||
// key type
|
||||
if (po + 4 > privSection.length) throw new Error('Malformed private key type length');
|
||||
const ktLen = readUint32(privSection, po); po += 4;
|
||||
if (po + ktLen > privSection.length) throw new Error('Malformed private key type');
|
||||
const keyType = this.decodeText(privSection.slice(po, po + ktLen), 'private key type'); po += ktLen;
|
||||
|
||||
// Parse based on key type
|
||||
if (keyType === SSH_ED25519) {
|
||||
return this.parseEd25519Key(privSection, po, publicKeyBlob);
|
||||
} else if (keyType === SSH_RSA) {
|
||||
return this.parseRSAKey(privSection, po, publicKeyBlob);
|
||||
} else if (keyType.startsWith('ecdsa-sha2-')) {
|
||||
return this.parseECDSAKey(privSection, po, keyType, publicKeyBlob);
|
||||
} else {
|
||||
throw new Error(`Unsupported key type: ${keyType}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Ed25519 private key from OpenSSH format.
|
||||
*/
|
||||
private static async parseEd25519Key(
|
||||
privSection: Uint8Array,
|
||||
offset: number,
|
||||
outerPublicKeyBlob: Uint8Array,
|
||||
): Promise<ParsedKey> {
|
||||
let po = offset;
|
||||
|
||||
// public key (32 bytes)
|
||||
if (po + 4 > privSection.length) throw new Error('Malformed public key length');
|
||||
const pubKeyLen = readUint32(privSection, po); po += 4;
|
||||
if (po + pubKeyLen > privSection.length) throw new Error('Malformed public key');
|
||||
const pubKeyRaw = privSection.slice(po, po + pubKeyLen); po += pubKeyLen;
|
||||
if (pubKeyRaw.length !== 32) throw new Error('Malformed Ed25519 public key length');
|
||||
|
||||
// private key (64 bytes = 32 bytes seed + 32 bytes pubkey)
|
||||
if (po + 4 > privSection.length) throw new Error('Malformed private key length');
|
||||
const privKeyLen = readUint32(privSection, po); po += 4;
|
||||
if (po + privKeyLen > privSection.length) throw new Error('Malformed private key');
|
||||
const privKeyRaw = privSection.slice(po, po + privKeyLen); po += privKeyLen;
|
||||
if (privKeyRaw.length !== 64) throw new Error('Malformed Ed25519 private key length');
|
||||
if (!this.bytesEqual(pubKeyRaw, privKeyRaw.subarray(32))) {
|
||||
throw new Error('Ed25519 private key public suffix does not match its public key');
|
||||
}
|
||||
|
||||
this.validateEd25519PublicBlob(outerPublicKeyBlob, pubKeyRaw);
|
||||
this.validatePrivateSectionTail(privSection, po);
|
||||
|
||||
const seed = privKeyRaw.slice(0, 32);
|
||||
|
||||
const pkcs8 = this.buildEd25519PKCS8(seed);
|
||||
const signingKey = await crypto.subtle.importKey(
|
||||
'pkcs8', toBufferSource(pkcs8), { name: ED25519_ALGO }, false, ['sign']
|
||||
);
|
||||
const probe = new Uint8Array([0x43, 0x46, 0x57, 0x53]);
|
||||
const probeSignature = await crypto.subtle.sign(ED25519_ALGO, signingKey, toBufferSource(probe));
|
||||
const publicKey = await crypto.subtle.importKey('raw', toBufferSource(pubKeyRaw), { name: ED25519_ALGO }, false, ['verify']);
|
||||
if (!await crypto.subtle.verify(ED25519_ALGO, publicKey, probeSignature, toBufferSource(probe))) {
|
||||
throw new Error('Ed25519 private seed does not match its public key');
|
||||
}
|
||||
|
||||
return { signingKey, publicKeyBlob: outerPublicKeyBlob, keyType: SSH_ED25519 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse RSA private key from OpenSSH format.
|
||||
*/
|
||||
private static async parseRSAKey(
|
||||
privSection: Uint8Array,
|
||||
offset: number,
|
||||
outerPublicKeyBlob: Uint8Array,
|
||||
): Promise<ParsedKey> {
|
||||
let po = offset;
|
||||
|
||||
const readMPINT = (name: string): Uint8Array => {
|
||||
const field = this.readPositiveMPInt(privSection, po, `RSA ${name}`);
|
||||
po = field.offset;
|
||||
return field.value;
|
||||
};
|
||||
|
||||
const n = readMPINT('modulus');
|
||||
const e = readMPINT('public exponent');
|
||||
const d = readMPINT('private exponent');
|
||||
const iqmp = readMPINT('coefficient');
|
||||
const p = readMPINT('prime p');
|
||||
const q = readMPINT('prime q');
|
||||
|
||||
this.validateRSAPublicBlob(outerPublicKeyBlob, e, n);
|
||||
this.validateRSAComponents(n, e, d, iqmp, p, q);
|
||||
this.validatePrivateSectionTail(privSection, po);
|
||||
|
||||
const pkcs8 = this.buildRSAPKCS8(n, e, d, p, q, iqmp);
|
||||
|
||||
const signingKey = await crypto.subtle.importKey(
|
||||
'pkcs8', toBufferSource(pkcs8), RSA_ALGO, false, ['sign']
|
||||
);
|
||||
|
||||
return { signingKey, rsaPkcs8: pkcs8, publicKeyBlob: outerPublicKeyBlob, keyType: SSH_RSA };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ECDSA private key from OpenSSH format.
|
||||
*/
|
||||
private static async parseECDSAKey(
|
||||
privSection: Uint8Array,
|
||||
offset: number,
|
||||
keyType: string,
|
||||
outerPublicKeyBlob: Uint8Array,
|
||||
): Promise<ParsedKey> {
|
||||
let po = offset;
|
||||
|
||||
let namedCurve: string;
|
||||
let algo: EcKeyImportParams;
|
||||
|
||||
if (keyType === ECDSA_SHA2_NISTP256) {
|
||||
namedCurve = 'P-256';
|
||||
algo = ECDSA_P256_ALGO;
|
||||
} else if (keyType === ECDSA_SHA2_NISTP384) {
|
||||
namedCurve = 'P-384';
|
||||
algo = ECDSA_P384_ALGO;
|
||||
} else if (keyType === ECDSA_SHA2_NISTP521) {
|
||||
namedCurve = 'P-521';
|
||||
algo = ECDSA_P521_ALGO;
|
||||
} else {
|
||||
throw new Error(`Unsupported ECDSA curve: ${keyType}`);
|
||||
}
|
||||
|
||||
// curve name
|
||||
if (po + 4 > privSection.length) throw new Error('Malformed private key curve length');
|
||||
const curveLen = readUint32(privSection, po); po += 4;
|
||||
if (po + curveLen > privSection.length) throw new Error('Malformed private key curve');
|
||||
const curve = this.decodeText(privSection.slice(po, po + curveLen), 'private key curve'); po += curveLen;
|
||||
|
||||
const expectedCurve = namedCurve.replace('P-', 'nistp');
|
||||
if (curve !== expectedCurve) {
|
||||
throw new Error(`Curve mismatch: expected ${expectedCurve}; received ${curve}`);
|
||||
}
|
||||
|
||||
// public key
|
||||
if (po + 4 > privSection.length) throw new Error('Malformed public key length');
|
||||
const pubKeyLen = readUint32(privSection, po); po += 4;
|
||||
if (po + pubKeyLen > privSection.length) throw new Error('Malformed public key');
|
||||
const pubKeyRaw = privSection.slice(po, po + pubKeyLen); po += pubKeyLen;
|
||||
|
||||
// private key
|
||||
if (po + 4 > privSection.length) throw new Error('Malformed private key length');
|
||||
const scalar = this.readPositiveMPInt(privSection, po, 'ECDSA private scalar');
|
||||
po = scalar.offset;
|
||||
|
||||
const coordBytes = this.ecdsaCoordBytes(keyType);
|
||||
if (scalar.value.length > coordBytes) throw new Error('Malformed ECDSA private scalar length');
|
||||
const privKeyRaw = new Uint8Array(coordBytes);
|
||||
privKeyRaw.set(scalar.value, coordBytes - scalar.value.length);
|
||||
|
||||
if (pubKeyRaw.length !== 1 + (coordBytes * 2) || pubKeyRaw[0] !== 0x04) {
|
||||
throw new Error('Malformed ECDSA public key point');
|
||||
}
|
||||
this.validateECDSAPublicBlob(outerPublicKeyBlob, keyType, curve, pubKeyRaw);
|
||||
this.validatePrivateSectionTail(privSection, po);
|
||||
|
||||
const pkcs8 = this.buildECDSAPKCS8(namedCurve, privKeyRaw);
|
||||
|
||||
const signingKey = await crypto.subtle.importKey(
|
||||
'pkcs8', toBufferSource(pkcs8), algo, false, ['sign']
|
||||
);
|
||||
const publicKey = await crypto.subtle.importKey('raw', toBufferSource(pubKeyRaw), algo, false, ['verify']);
|
||||
const probe = new Uint8Array([0x43, 0x46, 0x57, 0x53]);
|
||||
const probeSignature = await crypto.subtle.sign(
|
||||
{ name: 'ECDSA', hash: this.ecdsaHashForCurve(keyType) },
|
||||
signingKey,
|
||||
toBufferSource(probe),
|
||||
);
|
||||
if (!await crypto.subtle.verify(
|
||||
{ name: 'ECDSA', hash: this.ecdsaHashForCurve(keyType) },
|
||||
publicKey,
|
||||
probeSignature,
|
||||
toBufferSource(probe),
|
||||
)) throw new Error('ECDSA private scalar does not match its public key');
|
||||
|
||||
return { signingKey, publicKeyBlob: outerPublicKeyBlob, keyType };
|
||||
}
|
||||
|
||||
private static validateEd25519PublicBlob(blob: Uint8Array, publicKey: Uint8Array): void {
|
||||
let offset = 0;
|
||||
const type = this.readSSHString(blob, offset, 'Ed25519 public key type'); offset = type.offset;
|
||||
const key = this.readSSHString(blob, offset, 'Ed25519 public key'); offset = key.offset;
|
||||
if (this.decodeText(type.value, 'Ed25519 public key type') !== SSH_ED25519) {
|
||||
throw new Error('Ed25519 outer public key type does not match private key');
|
||||
}
|
||||
if (offset !== blob.length) throw new Error('Trailing data in Ed25519 public key blob');
|
||||
if (key.value.length !== 32 || !this.bytesEqual(key.value, publicKey)) {
|
||||
throw new Error('Ed25519 outer public key does not match private key');
|
||||
}
|
||||
}
|
||||
|
||||
private static validateRSAPublicBlob(blob: Uint8Array, exponent: Uint8Array, modulus: Uint8Array): void {
|
||||
let offset = 0;
|
||||
const type = this.readSSHString(blob, offset, 'RSA public key type'); offset = type.offset;
|
||||
if (this.decodeText(type.value, 'RSA public key type') !== SSH_RSA) {
|
||||
throw new Error('RSA outer public key type does not match private key');
|
||||
}
|
||||
const outerExponent = this.readPositiveMPInt(blob, offset, 'RSA outer public exponent');
|
||||
offset = outerExponent.offset;
|
||||
const outerModulus = this.readPositiveMPInt(blob, offset, 'RSA outer modulus');
|
||||
offset = outerModulus.offset;
|
||||
if (offset !== blob.length) throw new Error('Trailing data in RSA public key blob');
|
||||
if (!this.bytesEqual(outerExponent.value, exponent) || !this.bytesEqual(outerModulus.value, modulus)) {
|
||||
throw new Error('RSA outer public key does not match private key');
|
||||
}
|
||||
}
|
||||
|
||||
private static validateECDSAPublicBlob(
|
||||
blob: Uint8Array,
|
||||
keyType: string,
|
||||
curve: string,
|
||||
publicKey: Uint8Array,
|
||||
): void {
|
||||
let offset = 0;
|
||||
const outerType = this.readSSHString(blob, offset, 'ECDSA public key type'); offset = outerType.offset;
|
||||
const outerCurve = this.readSSHString(blob, offset, 'ECDSA public key curve'); offset = outerCurve.offset;
|
||||
const outerKey = this.readSSHString(blob, offset, 'ECDSA public key point'); offset = outerKey.offset;
|
||||
if (offset !== blob.length) throw new Error('Trailing data in ECDSA public key blob');
|
||||
if (this.decodeText(outerType.value, 'ECDSA public key type') !== keyType
|
||||
|| this.decodeText(outerCurve.value, 'ECDSA public key curve') !== curve
|
||||
|| !this.bytesEqual(outerKey.value, publicKey)) {
|
||||
throw new Error('ECDSA outer public key does not match private key');
|
||||
}
|
||||
}
|
||||
|
||||
private static validatePrivateSectionTail(section: Uint8Array, offset: number): void {
|
||||
const comment = this.readSSHString(section, offset, 'private key comment');
|
||||
const paddingLength = section.length - comment.offset;
|
||||
if (section.length % 8 !== 0 || paddingLength > 7) {
|
||||
throw new Error('Malformed OpenSSH private key padding');
|
||||
}
|
||||
for (let i = 0; i < paddingLength; i++) {
|
||||
if (section[comment.offset + i] !== i + 1) {
|
||||
throw new Error('Malformed OpenSSH private key padding');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static readSSHString(
|
||||
data: Uint8Array,
|
||||
offset: number,
|
||||
label: string,
|
||||
): { value: Uint8Array; offset: number } {
|
||||
if (offset + 4 > data.length) throw new Error(`Malformed ${label} length`);
|
||||
const length = readUint32(data, offset);
|
||||
offset += 4;
|
||||
if (offset + length > data.length) throw new Error(`Malformed ${label} data`);
|
||||
return { value: data.slice(offset, offset + length), offset: offset + length };
|
||||
}
|
||||
|
||||
private static readPositiveMPInt(
|
||||
data: Uint8Array,
|
||||
offset: number,
|
||||
label: string,
|
||||
): { value: Uint8Array; offset: number } {
|
||||
const field = this.readSSHString(data, offset, label);
|
||||
const value = field.value;
|
||||
if (value.length === 0 || (value[0] & 0x80) !== 0) {
|
||||
throw new Error(`Malformed ${label}: expected a positive MPINT`);
|
||||
}
|
||||
if (value[0] === 0) {
|
||||
if (value.length === 1 || (value[1] & 0x80) === 0) {
|
||||
throw new Error(`Malformed ${label}: non-canonical MPINT`);
|
||||
}
|
||||
return { value: value.slice(1), offset: field.offset };
|
||||
}
|
||||
return field;
|
||||
}
|
||||
|
||||
private static decodeText(data: Uint8Array, label: string): string {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(data);
|
||||
} catch {
|
||||
throw new Error(`Malformed ${label}: invalid UTF-8`);
|
||||
}
|
||||
}
|
||||
|
||||
private static bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let difference = 0;
|
||||
for (let i = 0; i < a.length; i++) difference |= a[i] ^ b[i];
|
||||
return difference === 0;
|
||||
}
|
||||
|
||||
private static validateRSAComponents(
|
||||
n: Uint8Array,
|
||||
e: Uint8Array,
|
||||
d: Uint8Array,
|
||||
iqmp: Uint8Array,
|
||||
p: Uint8Array,
|
||||
q: Uint8Array,
|
||||
): void {
|
||||
const toBigInt = (bytes: Uint8Array): bigint => {
|
||||
let value = 0n;
|
||||
for (const byte of bytes) value = (value << 8n) | BigInt(byte);
|
||||
return value;
|
||||
};
|
||||
const modulus = toBigInt(n);
|
||||
const exponent = toBigInt(e);
|
||||
const privateExponent = toBigInt(d);
|
||||
const primeP = toBigInt(p);
|
||||
const primeQ = toBigInt(q);
|
||||
const coefficient = toBigInt(iqmp);
|
||||
if (primeP <= 2n || primeQ <= 2n || modulus !== primeP * primeQ) throw new Error('RSA private factors do not match the modulus');
|
||||
if (exponent < 3n || (exponent & 1n) === 0n || privateExponent < 1n) throw new Error('Malformed RSA exponents');
|
||||
if ((coefficient * primeQ) % primeP !== 1n) throw new Error('RSA CRT coefficient does not match the private factors');
|
||||
const pMinus1 = primeP - 1n;
|
||||
const qMinus1 = primeQ - 1n;
|
||||
if ((privateExponent * exponent) % pMinus1 !== 1n || (privateExponent * exponent) % qMinus1 !== 1n) {
|
||||
throw new Error('RSA private exponent does not match the public exponent');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build PKCS#8 DER format for Ed25519 seed.
|
||||
*/
|
||||
private static buildEd25519PKCS8(seed: Uint8Array): Uint8Array {
|
||||
const oid = new Uint8Array([0x06, 0x03, 0x2b, 0x65, 0x70]);
|
||||
const seedOctet = new Uint8Array([0x04, seed.length, ...seed]);
|
||||
const innerOctet = new Uint8Array([0x04, seedOctet.length, ...seedOctet]);
|
||||
const algoSeq = new Uint8Array([0x30, oid.length, ...oid]);
|
||||
const version = new Uint8Array([0x02, 0x01, 0x00]);
|
||||
const totalLen = version.length + algoSeq.length + innerOctet.length;
|
||||
return new Uint8Array([0x30, totalLen, ...version, ...algoSeq, ...innerOctet]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build PKCS#8 DER format for RSA private key.
|
||||
*/
|
||||
private static buildRSAPKCS8(
|
||||
n: Uint8Array, e: Uint8Array, d: Uint8Array,
|
||||
p: Uint8Array, q: Uint8Array, iqmp: Uint8Array
|
||||
): Uint8Array {
|
||||
const pkcs1 = this.buildRSAPKCS1(n, e, d, p, q, iqmp);
|
||||
|
||||
const rsaOid = new Uint8Array([0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01]);
|
||||
const nullParam = new Uint8Array([0x05, 0x00]);
|
||||
const algoSeq = this.buildDERSequence(concat(rsaOid, nullParam));
|
||||
|
||||
const version = new Uint8Array([0x02, 0x01, 0x00]);
|
||||
const privKeyOctet = this.buildDEROctetString(pkcs1);
|
||||
|
||||
return this.buildDERSequence(concat(version, algoSeq, privKeyOctet));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build PKCS#1 RSAPrivateKey DER format.
|
||||
*/
|
||||
private static buildRSAPKCS1(
|
||||
n: Uint8Array, e: Uint8Array, d: Uint8Array,
|
||||
p: Uint8Array, q: Uint8Array, iqmp: Uint8Array
|
||||
): Uint8Array {
|
||||
const version = this.buildDERInteger(new Uint8Array([0]));
|
||||
const modulus = this.buildDERInteger(n);
|
||||
const publicExp = this.buildDERInteger(e);
|
||||
const privateExp = this.buildDERInteger(d);
|
||||
const prime1 = this.buildDERInteger(p);
|
||||
const prime2 = this.buildDERInteger(q);
|
||||
|
||||
const pMinus1 = this.bigIntSubtract(p, new Uint8Array([1]));
|
||||
const qMinus1 = this.bigIntSubtract(q, new Uint8Array([1]));
|
||||
const exponent1 = this.buildDERInteger(this.bigIntMod(d, pMinus1));
|
||||
const exponent2 = this.buildDERInteger(this.bigIntMod(d, qMinus1));
|
||||
const coefficient = this.buildDERInteger(iqmp);
|
||||
|
||||
return this.buildDERSequence(
|
||||
concat(version, modulus, publicExp, privateExp, prime1, prime2, exponent1, exponent2, coefficient)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build PKCS#8 DER format for ECDSA private key.
|
||||
*/
|
||||
private static buildECDSAPKCS8(namedCurve: string, privateKey: Uint8Array): Uint8Array {
|
||||
let curveOid: Uint8Array;
|
||||
if (namedCurve === 'P-256') {
|
||||
curveOid = new Uint8Array([0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07]);
|
||||
} else if (namedCurve === 'P-384') {
|
||||
curveOid = new Uint8Array([0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x22]);
|
||||
} else if (namedCurve === 'P-521') {
|
||||
curveOid = new Uint8Array([0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x23]);
|
||||
} else {
|
||||
throw new Error(`Unsupported curve: ${namedCurve}`);
|
||||
}
|
||||
|
||||
const ecVersion = this.buildDERInteger(new Uint8Array([1]));
|
||||
const ecPrivKeyOctet = this.buildDEROctetString(privateKey);
|
||||
const parameters = new Uint8Array([0xa0, curveOid.length, ...curveOid]);
|
||||
const ecPrivateKey = this.buildDERSequence(concat(ecVersion, ecPrivKeyOctet, parameters));
|
||||
|
||||
const ecOid = new Uint8Array([0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01]);
|
||||
const algoSeq = this.buildDERSequence(concat(ecOid, curveOid));
|
||||
|
||||
const pkcs8Version = this.buildDERInteger(new Uint8Array([0]));
|
||||
const privateKeyOctet = this.buildDEROctetString(ecPrivateKey);
|
||||
|
||||
return this.buildDERSequence(concat(pkcs8Version, algoSeq, privateKeyOctet));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build DER INTEGER.
|
||||
*/
|
||||
private static buildDERInteger(value: Uint8Array): Uint8Array {
|
||||
let data = value;
|
||||
if (data.length > 0 && (data[0] & 0x80) !== 0) {
|
||||
data = concat(new Uint8Array([0]), data);
|
||||
}
|
||||
while (data.length > 1 && data[0] === 0 && data[1] === 0) {
|
||||
data = data.slice(1);
|
||||
}
|
||||
|
||||
return concat(
|
||||
new Uint8Array([0x02]),
|
||||
this.encodeDERLength(data.length),
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build DER OCTET STRING.
|
||||
*/
|
||||
private static buildDEROctetString(data: Uint8Array): Uint8Array {
|
||||
return concat(
|
||||
new Uint8Array([0x04]),
|
||||
this.encodeDERLength(data.length),
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build DER SEQUENCE.
|
||||
*/
|
||||
private static buildDERSequence(data: Uint8Array): Uint8Array {
|
||||
return concat(
|
||||
new Uint8Array([0x30]),
|
||||
this.encodeDERLength(data.length),
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode DER length.
|
||||
*/
|
||||
private static encodeDERLength(length: number): Uint8Array {
|
||||
if (length < 0x80) {
|
||||
return new Uint8Array([length]);
|
||||
} else if (length < 0x100) {
|
||||
return new Uint8Array([0x81, length]);
|
||||
} else if (length < 0x10000) {
|
||||
return new Uint8Array([0x82, (length >> 8) & 0xff, length & 0xff]);
|
||||
} else {
|
||||
throw new Error('DER length is out of range');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ECDSA DER signature to SSH format (r || s).
|
||||
*/
|
||||
private static convertECDSADERToSSH(derSignature: Uint8Array): Uint8Array {
|
||||
let offset = 0;
|
||||
|
||||
if (derSignature.length < 8 || derSignature[offset] !== 0x30) throw new Error('Invalid DER signature');
|
||||
offset++;
|
||||
|
||||
const sequence = this.readDERLength(derSignature, offset);
|
||||
offset = sequence.offset;
|
||||
if (sequence.length !== derSignature.length - offset) throw new Error('Invalid DER signature length');
|
||||
|
||||
if (derSignature[offset] !== 0x02) throw new Error('Invalid DER signature');
|
||||
offset++;
|
||||
|
||||
const rLength = this.readDERLength(derSignature, offset);
|
||||
offset = rLength.offset;
|
||||
if (rLength.length === 0 || rLength.length > derSignature.length - offset) throw new Error('Invalid DER signature integer');
|
||||
const r = derSignature.slice(offset, offset + rLength.length);
|
||||
offset += rLength.length;
|
||||
|
||||
if (derSignature[offset] !== 0x02) throw new Error('Invalid DER signature');
|
||||
offset++;
|
||||
|
||||
const sLength = this.readDERLength(derSignature, offset);
|
||||
offset = sLength.offset;
|
||||
if (sLength.length === 0 || sLength.length !== derSignature.length - offset) throw new Error('Invalid DER signature integer');
|
||||
const s = derSignature.slice(offset, offset + sLength.length);
|
||||
|
||||
for (const integer of [r, s]) {
|
||||
if ((integer[0] & 0x80) !== 0 || (integer.length > 1 && integer[0] === 0 && (integer[1] & 0x80) === 0)) {
|
||||
throw new Error('Invalid DER signature integer encoding');
|
||||
}
|
||||
}
|
||||
|
||||
return concat(this.sshMPInt(r), this.sshMPInt(s));
|
||||
}
|
||||
|
||||
private static readDERLength(data: Uint8Array, offset: number): { length: number; offset: number } {
|
||||
if (offset >= data.length) throw new Error('Invalid DER length');
|
||||
const first = data[offset++];
|
||||
if (first < 0x80) return { length: first, offset };
|
||||
const bytes = first & 0x7f;
|
||||
if (bytes === 0 || bytes > 4 || offset + bytes > data.length || data[offset] === 0) throw new Error('Invalid DER length');
|
||||
let length = 0;
|
||||
for (let index = 0; index < bytes; index++) length = (length * 256) + data[offset + index];
|
||||
if (length < 0x80 || !Number.isSafeInteger(length)) throw new Error('Invalid DER length');
|
||||
return { length, offset: offset + bytes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode MPINT in SSH format.
|
||||
*/
|
||||
private static sshMPInt(value: Uint8Array): Uint8Array {
|
||||
let start = 0;
|
||||
while (start < value.length - 1 && value[start] === 0) {
|
||||
start++;
|
||||
}
|
||||
const significant = value.subarray(start);
|
||||
|
||||
const needsLeadingZero = significant.length > 0 && (significant[0] & 0x80) !== 0;
|
||||
const data = needsLeadingZero
|
||||
? concat(new Uint8Array([0]), significant)
|
||||
: significant;
|
||||
|
||||
return encodeString(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtract two big integers (big-endian).
|
||||
*/
|
||||
private static bigIntSubtract(a: Uint8Array, b: Uint8Array): Uint8Array {
|
||||
const result = new Uint8Array(a.length);
|
||||
let borrow = 0;
|
||||
|
||||
for (let i = a.length - 1; i >= 0; i--) {
|
||||
const aByte = a[i];
|
||||
const bByte = i >= a.length - b.length ? b[b.length - (a.length - i)] : 0;
|
||||
|
||||
let diff = aByte - bByte - borrow;
|
||||
if (diff < 0) {
|
||||
diff += 256;
|
||||
borrow = 1;
|
||||
} else {
|
||||
borrow = 0;
|
||||
}
|
||||
result[i] = diff;
|
||||
}
|
||||
|
||||
let start = 0;
|
||||
while (start < result.length - 1 && result[start] === 0) {
|
||||
start++;
|
||||
}
|
||||
return result.slice(start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate a mod m for big integers.
|
||||
*/
|
||||
private static bigIntMod(a: Uint8Array, m: Uint8Array): Uint8Array {
|
||||
const toBigInt = (bytes: Uint8Array): bigint => {
|
||||
let n = 0n;
|
||||
for (const b of bytes) n = (n << 8n) | BigInt(b);
|
||||
return n;
|
||||
};
|
||||
const r = toBigInt(a) % toBigInt(m);
|
||||
const hex = r.toString(16).padStart(2, '0');
|
||||
const padded = hex.length % 2 ? '0' + hex : hex;
|
||||
const out = new Uint8Array(padded.length / 2);
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(padded.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import {
|
||||
SSH_MSG_CHANNEL_OPEN,
|
||||
SSH_MSG_CHANNEL_WINDOW_ADJUST,
|
||||
SSH_MSG_CHANNEL_DATA,
|
||||
SSH_MSG_CHANNEL_REQUEST,
|
||||
SSH_MSG_CHANNEL_EOF,
|
||||
SSH_MSG_CHANNEL_CLOSE,
|
||||
} from '../types';
|
||||
import { encodeString, readUint32, writeUint32 } from './utils';
|
||||
|
||||
const SESSION_FIELD = encodeString('session');
|
||||
const PTY_REQ_FIELD = encodeString('pty-req');
|
||||
const SHELL_FIELD = encodeString('shell');
|
||||
const SUBSYSTEM_FIELD = encodeString('subsystem');
|
||||
const EXEC_FIELD = encodeString('exec');
|
||||
const WINDOW_CHANGE_FIELD = encodeString('window-change');
|
||||
const EMPTY_TERMINAL_MODES_FIELD = encodeString(new Uint8Array([0]));
|
||||
const UINT32_MAX = 0xffffffff;
|
||||
const DEFAULT_WINDOW_SIZE = 2 * 1024 * 1024;
|
||||
const DEFAULT_MAX_PACKET_SIZE = 32 * 1024;
|
||||
|
||||
export interface ChannelDataChunk {
|
||||
source: Uint8Array;
|
||||
sourceOffset: number;
|
||||
bytesConsumed: number;
|
||||
payloadLength: number;
|
||||
}
|
||||
|
||||
function writeBytes(target: Uint8Array, offset: number, source: Uint8Array): number {
|
||||
target.set(source, offset);
|
||||
return offset + source.length;
|
||||
}
|
||||
|
||||
export class SSHChannel {
|
||||
private localChannelID: number = 0;
|
||||
private remoteChannelID: number = 0;
|
||||
private localWindowSize: number = DEFAULT_WINDOW_SIZE;
|
||||
private remoteWindowSize: number = 0;
|
||||
private remoteMaxPacketSize: number = DEFAULT_MAX_PACKET_SIZE;
|
||||
private consumedSinceWindowAdjust: number = 0;
|
||||
private eofSent: boolean = false;
|
||||
private eofReceived: boolean = false;
|
||||
private closeSent: boolean = false;
|
||||
private closeReceived: boolean = false;
|
||||
private openConfirmed: boolean = false;
|
||||
|
||||
getLocalChannelID(): number {
|
||||
return this.localChannelID;
|
||||
}
|
||||
|
||||
getRemoteChannelID(): number {
|
||||
return this.remoteChannelID;
|
||||
}
|
||||
|
||||
isClosed(): boolean {
|
||||
return this.closeSent && this.closeReceived;
|
||||
}
|
||||
|
||||
hasSentClose(): boolean {
|
||||
return this.closeSent;
|
||||
}
|
||||
|
||||
buildOpenSession(channelID: number = 0): Uint8Array {
|
||||
this.localChannelID = channelID;
|
||||
|
||||
const payload = new Uint8Array(1 + SESSION_FIELD.length + 12);
|
||||
let offset = 0;
|
||||
payload[offset++] = SSH_MSG_CHANNEL_OPEN;
|
||||
offset = writeBytes(payload, offset, SESSION_FIELD);
|
||||
writeUint32(payload, offset, this.localChannelID);
|
||||
offset += 4;
|
||||
writeUint32(payload, offset, DEFAULT_WINDOW_SIZE);
|
||||
offset += 4;
|
||||
writeUint32(payload, offset, DEFAULT_MAX_PACKET_SIZE);
|
||||
return payload;
|
||||
}
|
||||
|
||||
handleOpenConfirmation(payload: Uint8Array): void {
|
||||
if (payload.length !== 17) throw new Error('Malformed channel open confirmation');
|
||||
if (readUint32(payload, 1) !== this.localChannelID) throw new Error('Channel confirmation has an unexpected recipient');
|
||||
if (this.openConfirmed) throw new Error('Duplicate channel open confirmation');
|
||||
let offset = 1;
|
||||
offset += 4;
|
||||
this.remoteChannelID = readUint32(payload, offset);
|
||||
offset += 4;
|
||||
this.remoteWindowSize = readUint32(payload, offset);
|
||||
offset += 4;
|
||||
const serverMaxPacket = readUint32(payload, offset);
|
||||
if (serverMaxPacket === 0) throw new Error('Channel maximum packet size must be positive');
|
||||
this.remoteMaxPacketSize = serverMaxPacket;
|
||||
this.openConfirmed = true;
|
||||
}
|
||||
|
||||
handleOpenFailure(payload: Uint8Array): void {
|
||||
if (payload.length < 17 || readUint32(payload, 1) !== this.localChannelID) {
|
||||
throw new Error('Malformed channel open failure');
|
||||
}
|
||||
let offset = 9;
|
||||
for (let index = 0; index < 2; index++) {
|
||||
if (offset + 4 > payload.length) throw new Error('Malformed channel open failure');
|
||||
const length = readUint32(payload, offset);
|
||||
offset += 4;
|
||||
if (length > payload.length - offset) throw new Error('Malformed channel open failure');
|
||||
offset += length;
|
||||
}
|
||||
if (offset !== payload.length) throw new Error('Malformed channel open failure');
|
||||
}
|
||||
|
||||
buildPTYRequest(cols: number, rows: number, terminal = 'xterm-256color'): Uint8Array {
|
||||
const terminalField = encodeString(terminal);
|
||||
const payload = new Uint8Array(
|
||||
1 + 4 + PTY_REQ_FIELD.length + 1 + terminalField.length + 16 + EMPTY_TERMINAL_MODES_FIELD.length
|
||||
);
|
||||
let offset = 0;
|
||||
payload[offset++] = SSH_MSG_CHANNEL_REQUEST;
|
||||
writeUint32(payload, offset, this.remoteChannelID);
|
||||
offset += 4;
|
||||
offset = writeBytes(payload, offset, PTY_REQ_FIELD);
|
||||
payload[offset++] = 0x01;
|
||||
offset = writeBytes(payload, offset, terminalField);
|
||||
writeUint32(payload, offset, cols);
|
||||
offset += 4;
|
||||
writeUint32(payload, offset, rows);
|
||||
offset += 4;
|
||||
writeUint32(payload, offset, 0);
|
||||
offset += 4;
|
||||
writeUint32(payload, offset, 0);
|
||||
offset += 4;
|
||||
writeBytes(payload, offset, EMPTY_TERMINAL_MODES_FIELD);
|
||||
return payload;
|
||||
}
|
||||
|
||||
buildShellRequest(): Uint8Array {
|
||||
const payload = new Uint8Array(1 + 4 + SHELL_FIELD.length + 1);
|
||||
let offset = 0;
|
||||
payload[offset++] = SSH_MSG_CHANNEL_REQUEST;
|
||||
writeUint32(payload, offset, this.remoteChannelID);
|
||||
offset += 4;
|
||||
offset = writeBytes(payload, offset, SHELL_FIELD);
|
||||
payload[offset] = 0x01;
|
||||
return payload;
|
||||
}
|
||||
|
||||
buildSubsystemRequest(subsystem: string): Uint8Array {
|
||||
const name = encodeString(subsystem);
|
||||
const payload = new Uint8Array(1 + 4 + SUBSYSTEM_FIELD.length + 1 + name.length);
|
||||
let offset = 0;
|
||||
payload[offset++] = SSH_MSG_CHANNEL_REQUEST;
|
||||
writeUint32(payload, offset, this.remoteChannelID);
|
||||
offset += 4;
|
||||
offset = writeBytes(payload, offset, SUBSYSTEM_FIELD);
|
||||
payload[offset++] = 0x01; // want_reply = true
|
||||
writeBytes(payload, offset, name);
|
||||
return payload;
|
||||
}
|
||||
|
||||
buildExecRequest(command: string): Uint8Array {
|
||||
const cmdBytes = encodeString(command);
|
||||
const payload = new Uint8Array(1 + 4 + EXEC_FIELD.length + 1 + cmdBytes.length);
|
||||
let offset = 0;
|
||||
payload[offset++] = SSH_MSG_CHANNEL_REQUEST;
|
||||
writeUint32(payload, offset, this.remoteChannelID);
|
||||
offset += 4;
|
||||
offset = writeBytes(payload, offset, EXEC_FIELD);
|
||||
payload[offset++] = 0x01; // want_reply = true
|
||||
writeBytes(payload, offset, cmdBytes);
|
||||
return payload;
|
||||
}
|
||||
|
||||
buildEof(): Uint8Array {
|
||||
if (this.eofSent || this.closeSent) throw new Error('Channel EOF was already sent');
|
||||
this.eofSent = true;
|
||||
const payload = new Uint8Array(5);
|
||||
payload[0] = SSH_MSG_CHANNEL_EOF;
|
||||
writeUint32(payload, 1, this.remoteChannelID);
|
||||
return payload;
|
||||
}
|
||||
|
||||
buildClose(): Uint8Array {
|
||||
if (this.closeSent) throw new Error('Channel close was already sent');
|
||||
this.closeSent = true;
|
||||
const payload = new Uint8Array(5);
|
||||
payload[0] = SSH_MSG_CHANNEL_CLOSE;
|
||||
writeUint32(payload, 1, this.remoteChannelID);
|
||||
return payload;
|
||||
}
|
||||
|
||||
takeChannelDataChunk(data: Uint8Array, offset: number = 0): ChannelDataChunk | null {
|
||||
const bytesAvailable = data.length - offset;
|
||||
if (bytesAvailable <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytesToSend = Math.min(bytesAvailable, this.remoteMaxPacketSize, this.remoteWindowSize);
|
||||
if (bytesToSend <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.remoteWindowSize -= bytesToSend;
|
||||
return {
|
||||
source: data,
|
||||
sourceOffset: offset,
|
||||
bytesConsumed: bytesToSend,
|
||||
payloadLength: 9 + bytesToSend,
|
||||
};
|
||||
}
|
||||
|
||||
writeChannelDataPayload(
|
||||
target: Uint8Array,
|
||||
offset: number,
|
||||
source: Uint8Array,
|
||||
sourceOffset: number,
|
||||
sourceLength: number
|
||||
): void {
|
||||
target[offset] = SSH_MSG_CHANNEL_DATA;
|
||||
writeUint32(target, offset + 1, this.remoteChannelID);
|
||||
writeUint32(target, offset + 5, sourceLength);
|
||||
target.set(source.subarray(sourceOffset, sourceOffset + sourceLength), offset + 9);
|
||||
}
|
||||
|
||||
handleWindowAdjust(payload: Uint8Array): number {
|
||||
if (payload.length !== 9) throw new Error('Malformed channel window adjustment');
|
||||
const recipientChannelID = readUint32(payload, 1);
|
||||
if (recipientChannelID !== this.localChannelID) {
|
||||
throw new Error('Channel window adjustment has an unexpected recipient');
|
||||
}
|
||||
|
||||
const bytesToAdd = readUint32(payload, 5);
|
||||
if (bytesToAdd === 0 || bytesToAdd > UINT32_MAX - this.remoteWindowSize) {
|
||||
throw new Error('Invalid channel window adjustment');
|
||||
}
|
||||
this.remoteWindowSize += bytesToAdd;
|
||||
return bytesToAdd;
|
||||
}
|
||||
|
||||
handleChannelData(payload: Uint8Array): Uint8Array {
|
||||
if (payload.length < 9) throw new Error('Malformed channel data');
|
||||
let offset = 1;
|
||||
const recipientChannelID = readUint32(payload, offset);
|
||||
if (recipientChannelID !== this.localChannelID) throw new Error('Channel data has an unexpected recipient');
|
||||
offset += 4;
|
||||
const dataLen = readUint32(payload, offset);
|
||||
offset += 4;
|
||||
if (dataLen !== payload.length - offset) throw new Error('Malformed channel data');
|
||||
this.consumeLocalWindow(dataLen);
|
||||
return payload.subarray(offset, offset + dataLen);
|
||||
}
|
||||
|
||||
handleExtendedData(payload: Uint8Array): Uint8Array {
|
||||
if (payload.length < 13) throw new Error('Malformed extended channel data');
|
||||
if (readUint32(payload, 1) !== this.localChannelID) throw new Error('Extended channel data has an unexpected recipient');
|
||||
const dataLen = readUint32(payload, 9);
|
||||
if (dataLen !== payload.length - 13) throw new Error('Malformed extended channel data');
|
||||
this.consumeLocalWindow(dataLen);
|
||||
return payload.subarray(13);
|
||||
}
|
||||
|
||||
handleRequestResult(payload: Uint8Array): void {
|
||||
this.validateRecipientOnlyMessage(payload, 'channel request result');
|
||||
}
|
||||
|
||||
handleEof(payload: Uint8Array): void {
|
||||
this.validateRecipientOnlyMessage(payload, 'channel EOF');
|
||||
if (this.eofReceived) throw new Error('Duplicate channel EOF');
|
||||
this.eofReceived = true;
|
||||
}
|
||||
|
||||
handleClose(payload: Uint8Array): void {
|
||||
this.validateRecipientOnlyMessage(payload, 'channel close');
|
||||
if (this.closeReceived) throw new Error('Duplicate channel close');
|
||||
this.closeReceived = true;
|
||||
}
|
||||
|
||||
buildWindowChange(cols: number, rows: number): Uint8Array {
|
||||
const payload = new Uint8Array(1 + 4 + WINDOW_CHANGE_FIELD.length + 1 + 16);
|
||||
let offset = 0;
|
||||
payload[offset++] = SSH_MSG_CHANNEL_REQUEST;
|
||||
writeUint32(payload, offset, this.remoteChannelID);
|
||||
offset += 4;
|
||||
offset = writeBytes(payload, offset, WINDOW_CHANGE_FIELD);
|
||||
payload[offset++] = 0x00;
|
||||
writeUint32(payload, offset, cols);
|
||||
offset += 4;
|
||||
writeUint32(payload, offset, rows);
|
||||
offset += 4;
|
||||
writeUint32(payload, offset, 0);
|
||||
offset += 4;
|
||||
writeUint32(payload, offset, 0);
|
||||
return payload;
|
||||
}
|
||||
|
||||
buildWindowAdjust(bytesToAdd: number): Uint8Array {
|
||||
const payload = new Uint8Array(9);
|
||||
payload[0] = SSH_MSG_CHANNEL_WINDOW_ADJUST;
|
||||
writeUint32(payload, 1, this.remoteChannelID);
|
||||
writeUint32(payload, 5, bytesToAdd);
|
||||
return payload;
|
||||
}
|
||||
|
||||
takeLocalWindowAdjustment(threshold: number): number | null {
|
||||
if (!Number.isInteger(threshold) || threshold < 1) throw new Error('Invalid local channel window threshold');
|
||||
if (this.consumedSinceWindowAdjust < threshold) {
|
||||
return null;
|
||||
}
|
||||
const amount = this.consumedSinceWindowAdjust;
|
||||
this.consumedSinceWindowAdjust = 0;
|
||||
this.localWindowSize += amount;
|
||||
return amount;
|
||||
}
|
||||
|
||||
private consumeLocalWindow(bytes: number): void {
|
||||
// CHANNEL_EOF ends the sender's ordinary data stream. Some servers still
|
||||
// send request metadata before CLOSE, but additional channel data is invalid.
|
||||
if (this.eofReceived || this.closeReceived) throw new Error('Channel data received after EOF or close');
|
||||
if (bytes > DEFAULT_MAX_PACKET_SIZE) throw new Error('Channel data exceeds the advertised maximum packet size');
|
||||
if (bytes > this.localWindowSize) throw new Error('Channel data exceeds the local receive window');
|
||||
this.localWindowSize -= bytes;
|
||||
this.consumedSinceWindowAdjust += bytes;
|
||||
}
|
||||
|
||||
private validateRecipientOnlyMessage(payload: Uint8Array, label: string): void {
|
||||
if (payload.length !== 5) throw new Error(`Malformed ${label}`);
|
||||
if (readUint32(payload, 1) !== this.localChannelID) throw new Error(`${label} has an unexpected recipient`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { toBufferSource, writeUint32 } from './utils';
|
||||
|
||||
function buildMacData(seqNum: number, packet: Uint8Array): Uint8Array {
|
||||
const data = new Uint8Array(4 + packet.length);
|
||||
writeUint32(data, 0, seqNum);
|
||||
data.set(packet, 4);
|
||||
return data;
|
||||
}
|
||||
|
||||
export class SSHAESGCMCipher {
|
||||
private key: CryptoKey | null = null;
|
||||
private iv: Uint8Array;
|
||||
private rawKey: Uint8Array;
|
||||
|
||||
constructor(rawKey: Uint8Array, iv: Uint8Array) {
|
||||
if (iv.length !== 12) throw new Error(`AES-GCM requires a 12-byte IV, got ${iv.length}`);
|
||||
// Copy the IV so we own it; this is the mutable nonce state
|
||||
this.iv = new Uint8Array(iv);
|
||||
this.rawKey = rawKey;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
this.key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
toBufferSource(this.rawKey),
|
||||
{ name: 'AES-GCM', length: this.rawKey.length * 8 },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the 64-bit invocation counter stored in IV bytes [4..11]
|
||||
* using big-endian carry-over logic, per RFC 5647 section7.1.
|
||||
*/
|
||||
private incIV(): void {
|
||||
for (let i = 11; i >= 4; i--) {
|
||||
this.iv[i]++;
|
||||
if (this.iv[i] !== 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getAlgorithm(aad?: Uint8Array): Record<string, unknown> {
|
||||
const algorithm: Record<string, unknown> = {
|
||||
name: 'AES-GCM',
|
||||
iv: this.iv,
|
||||
tagLength: 128,
|
||||
};
|
||||
if (aad) {
|
||||
algorithm.additionalData = aad;
|
||||
}
|
||||
return algorithm;
|
||||
}
|
||||
|
||||
async encrypt(plaintext: Uint8Array, _seqNum?: number, aad?: Uint8Array, _commit: boolean = true): Promise<Uint8Array> {
|
||||
if (!this.key) throw new Error('Cipher not initialized');
|
||||
const algorithm = this.getAlgorithm(aad);
|
||||
|
||||
const encrypted = new Uint8Array(
|
||||
await crypto.subtle.encrypt(algorithm as unknown as SubtleCryptoEncryptAlgorithm, this.key, toBufferSource(plaintext))
|
||||
);
|
||||
|
||||
this.incIV();
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
async decrypt(ciphertext: Uint8Array, _seqNum?: number, aad?: Uint8Array, _commit: boolean = true): Promise<Uint8Array | null> {
|
||||
if (!this.key) throw new Error('Cipher not initialized');
|
||||
const algorithm = this.getAlgorithm(aad);
|
||||
|
||||
try {
|
||||
const decrypted = new Uint8Array(
|
||||
await crypto.subtle.decrypt(algorithm as unknown as SubtleCryptoEncryptAlgorithm, this.key, toBufferSource(ciphertext))
|
||||
);
|
||||
this.incIV();
|
||||
return decrypted;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class SSHAESCTRCipher {
|
||||
private key: CryptoKey | null = null;
|
||||
private counter: Uint8Array;
|
||||
private rawKey: Uint8Array;
|
||||
|
||||
constructor(rawKey: Uint8Array, iv: Uint8Array) {
|
||||
if (iv.length !== 16) {
|
||||
throw new Error(`AES-CTR requires a 16-byte IV, got ${iv.length}`);
|
||||
}
|
||||
this.counter = new Uint8Array(iv);
|
||||
this.rawKey = rawKey;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
this.key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
toBufferSource(this.rawKey),
|
||||
{ name: 'AES-CTR', length: this.rawKey.length * 8 },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
private incCounter(blocks: number): void {
|
||||
let carry = blocks;
|
||||
for (let i = 15; i >= 0 && carry > 0; i--) {
|
||||
const add = carry % 256;
|
||||
const sum = this.counter[i] + add;
|
||||
this.counter[i] = sum & 0xff;
|
||||
carry = Math.floor(carry / 256) + (sum >>> 8);
|
||||
}
|
||||
}
|
||||
|
||||
async encrypt(plaintext: Uint8Array, _seqNum?: number, _aad?: Uint8Array, commit: boolean = true): Promise<Uint8Array> {
|
||||
if (!this.key) throw new Error('Cipher not initialized');
|
||||
const counter = new Uint8Array(this.counter);
|
||||
const encrypted = new Uint8Array(
|
||||
await crypto.subtle.encrypt(
|
||||
{ name: 'AES-CTR', counter, length: 128 } as SubtleCryptoEncryptAlgorithm,
|
||||
this.key,
|
||||
toBufferSource(plaintext)
|
||||
)
|
||||
);
|
||||
if (commit) {
|
||||
this.incCounter(Math.ceil(plaintext.length / 16));
|
||||
}
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
async decrypt(ciphertext: Uint8Array, _seqNum?: number, _aad?: Uint8Array, commit: boolean = true): Promise<Uint8Array | null> {
|
||||
if (!this.key) throw new Error('Cipher not initialized');
|
||||
const counter = new Uint8Array(this.counter);
|
||||
try {
|
||||
const decrypted = new Uint8Array(
|
||||
await crypto.subtle.decrypt(
|
||||
{ name: 'AES-CTR', counter, length: 128 } as SubtleCryptoEncryptAlgorithm,
|
||||
this.key,
|
||||
toBufferSource(ciphertext)
|
||||
)
|
||||
);
|
||||
if (commit) {
|
||||
this.incCounter(Math.ceil(ciphertext.length / 16));
|
||||
}
|
||||
return decrypted;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class SSHHMAC {
|
||||
private key: CryptoKey | null = null;
|
||||
private rawKey: Uint8Array;
|
||||
private hash: 'SHA-256' | 'SHA-512';
|
||||
readonly length: number;
|
||||
|
||||
constructor(algorithm: string, rawKey: Uint8Array) {
|
||||
this.rawKey = rawKey;
|
||||
if (algorithm === 'hmac-sha2-256') {
|
||||
this.hash = 'SHA-256';
|
||||
this.length = 32;
|
||||
} else if (algorithm === 'hmac-sha2-512') {
|
||||
this.hash = 'SHA-512';
|
||||
this.length = 64;
|
||||
} else {
|
||||
throw new Error(`Unsupported MAC algorithm: ${algorithm}`);
|
||||
}
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
this.key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
toBufferSource(this.rawKey),
|
||||
{ name: 'HMAC', hash: this.hash },
|
||||
false,
|
||||
['sign', 'verify']
|
||||
);
|
||||
}
|
||||
|
||||
async sign(packet: Uint8Array, seqNum: number): Promise<Uint8Array> {
|
||||
if (!this.key) throw new Error('MAC not initialized');
|
||||
return new Uint8Array(await crypto.subtle.sign('HMAC', this.key, toBufferSource(buildMacData(seqNum, packet))));
|
||||
}
|
||||
|
||||
async verify(packet: Uint8Array, seqNum: number, expected: Uint8Array): Promise<boolean> {
|
||||
if (!this.key) throw new Error('MAC not initialized');
|
||||
return crypto.subtle.verify('HMAC', this.key, toBufferSource(expected), toBufferSource(buildMacData(seqNum, packet)));
|
||||
}
|
||||
}
|
||||
|
||||
export const REKEY_THRESHOLD = 1 << 30;
|
||||
|
||||
export function shouldRekey(seqNum: number): boolean {
|
||||
return seqNum >= REKEY_THRESHOLD;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { SSH_MSG_KEX_ECDH_INIT } from '../types';
|
||||
import { concat, readUint32, encodeString, toBufferSource, x25519SecretToSSHMPInt } from './utils';
|
||||
|
||||
type X25519SubtleCrypto = {
|
||||
generateKey(
|
||||
algorithm: { name: 'X25519' },
|
||||
extractable: boolean,
|
||||
keyUsages: string[]
|
||||
): Promise<CryptoKeyPair>;
|
||||
exportKey(format: 'raw', key: CryptoKey): Promise<ArrayBuffer>;
|
||||
importKey(
|
||||
format: 'raw',
|
||||
keyData: ArrayBuffer | ArrayBufferView,
|
||||
algorithm: { name: 'X25519' },
|
||||
extractable: boolean,
|
||||
keyUsages: string[]
|
||||
): Promise<CryptoKey>;
|
||||
deriveBits(
|
||||
algorithm: { name: 'X25519'; public: CryptoKey },
|
||||
baseKey: CryptoKey,
|
||||
length: number
|
||||
): Promise<ArrayBuffer>;
|
||||
};
|
||||
|
||||
function x25519Subtle(): X25519SubtleCrypto {
|
||||
return crypto.subtle as unknown as X25519SubtleCrypto;
|
||||
}
|
||||
|
||||
function isAllZero(bytes: Uint8Array): boolean {
|
||||
for (const byte of bytes) {
|
||||
if (byte !== 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export type Curve25519KeyPair = CryptoKeyPair;
|
||||
|
||||
export class Curve25519KeyExchange {
|
||||
static async generateKeyPair(): Promise<Curve25519KeyPair> {
|
||||
return x25519Subtle().generateKey(
|
||||
{ name: 'X25519' },
|
||||
true,
|
||||
['deriveBits']
|
||||
);
|
||||
}
|
||||
|
||||
static async exportRawPublicKey(keyPair: Curve25519KeyPair): Promise<Uint8Array> {
|
||||
return new Uint8Array(
|
||||
await x25519Subtle().exportKey('raw', keyPair.publicKey)
|
||||
);
|
||||
}
|
||||
|
||||
static buildInit(clientRawPublicKey: Uint8Array): Uint8Array {
|
||||
if (clientRawPublicKey.length !== 32) {
|
||||
throw new Error(`Invalid Curve25519 client public key length: ${clientRawPublicKey.length}`);
|
||||
}
|
||||
|
||||
return concat(
|
||||
new Uint8Array([SSH_MSG_KEX_ECDH_INIT]),
|
||||
encodeString(clientRawPublicKey)
|
||||
);
|
||||
}
|
||||
|
||||
static parseReply(data: Uint8Array): {
|
||||
hostKey: Uint8Array;
|
||||
serverRawPublicKey: Uint8Array;
|
||||
signature: Uint8Array;
|
||||
} {
|
||||
if (data.length < 13 || data[0] !== 31) throw new Error('Malformed Curve25519 reply');
|
||||
let offset = 1;
|
||||
|
||||
const hostKeyLen = readUint32(data, offset);
|
||||
offset += 4;
|
||||
if (hostKeyLen === 0 || hostKeyLen > data.length - offset) throw new Error('Malformed Curve25519 host key');
|
||||
const hostKey = data.slice(offset, offset + hostKeyLen);
|
||||
offset += hostKeyLen;
|
||||
|
||||
if (offset + 4 > data.length) throw new Error('Malformed Curve25519 server key');
|
||||
const qSLen = readUint32(data, offset);
|
||||
offset += 4;
|
||||
if (qSLen !== 32 || qSLen > data.length - offset) throw new Error('Malformed Curve25519 server key');
|
||||
const serverRawPublicKey = data.slice(offset, offset + qSLen);
|
||||
offset += qSLen;
|
||||
|
||||
if (offset + 4 > data.length) throw new Error('Malformed Curve25519 signature');
|
||||
const sigLen = readUint32(data, offset);
|
||||
offset += 4;
|
||||
if (sigLen === 0 || sigLen !== data.length - offset) throw new Error('Malformed Curve25519 signature');
|
||||
const signature = data.slice(offset, offset + sigLen);
|
||||
|
||||
return { hostKey, serverRawPublicKey, signature };
|
||||
}
|
||||
|
||||
static async computeSharedSecret(
|
||||
privateKey: CryptoKey,
|
||||
serverRawPublicKey: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
if (serverRawPublicKey.length !== 32) {
|
||||
throw new Error(`Invalid Curve25519 server public key length: ${serverRawPublicKey.length}`);
|
||||
}
|
||||
|
||||
const serverKey = await x25519Subtle().importKey(
|
||||
'raw',
|
||||
serverRawPublicKey,
|
||||
{ name: 'X25519' },
|
||||
false,
|
||||
[]
|
||||
);
|
||||
|
||||
const sharedSecret = new Uint8Array(
|
||||
await x25519Subtle().deriveBits(
|
||||
{ name: 'X25519', public: serverKey },
|
||||
privateKey,
|
||||
256
|
||||
)
|
||||
);
|
||||
if (isAllZero(sharedSecret)) {
|
||||
throw new Error('Curve25519 key exchange failed: all-zero shared secret');
|
||||
}
|
||||
return x25519SecretToSSHMPInt(sharedSecret);
|
||||
}
|
||||
|
||||
static async computeExchangeHash(
|
||||
clientVersion: string,
|
||||
serverVersion: string,
|
||||
clientKEXInit: Uint8Array,
|
||||
serverKEXInit: Uint8Array,
|
||||
hostKey: Uint8Array,
|
||||
clientRawPublicKey: Uint8Array,
|
||||
serverRawPublicKey: Uint8Array,
|
||||
sharedSecret: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
const v_c = encodeString(clientVersion);
|
||||
const v_s = encodeString(serverVersion);
|
||||
const i_c = encodeString(clientKEXInit);
|
||||
const i_s = encodeString(serverKEXInit);
|
||||
const k_s = encodeString(hostKey);
|
||||
const e = encodeString(clientRawPublicKey);
|
||||
const f = encodeString(serverRawPublicKey);
|
||||
const k = sharedSecret;
|
||||
|
||||
const data = concat(v_c, v_s, i_c, i_s, k_s, e, f, k);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', toBufferSource(data));
|
||||
return new Uint8Array(hashBuffer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { SSH_MSG_KEX_ECDH_INIT } from '../types';
|
||||
import { concat, readUint32, encodeString, toBufferSource, toSSHMPInt } from './utils';
|
||||
|
||||
type ECDHSubtleCrypto = {
|
||||
generateKey(
|
||||
algorithm: { name: 'ECDH'; namedCurve: 'P-256' },
|
||||
extractable: boolean,
|
||||
keyUsages: string[]
|
||||
): Promise<CryptoKeyPair>;
|
||||
exportKey(format: 'raw', key: CryptoKey): Promise<ArrayBuffer>;
|
||||
importKey(
|
||||
format: 'raw',
|
||||
keyData: ArrayBuffer | ArrayBufferView,
|
||||
algorithm: { name: 'ECDH'; namedCurve: 'P-256' },
|
||||
extractable: boolean,
|
||||
keyUsages: string[]
|
||||
): Promise<CryptoKey>;
|
||||
deriveBits(
|
||||
algorithm: { name: 'ECDH'; public: CryptoKey },
|
||||
baseKey: CryptoKey,
|
||||
length: number
|
||||
): Promise<ArrayBuffer>;
|
||||
};
|
||||
|
||||
function ecdhSubtle(): ECDHSubtleCrypto {
|
||||
return crypto.subtle as unknown as ECDHSubtleCrypto;
|
||||
}
|
||||
|
||||
function isAllZero(bytes: Uint8Array): boolean {
|
||||
for (const byte of bytes) {
|
||||
if (byte !== 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export class ECDHKeyExchange {
|
||||
static async generateKeyPair(): Promise<CryptoKeyPair> {
|
||||
return ecdhSubtle().generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
true,
|
||||
['deriveBits']
|
||||
);
|
||||
}
|
||||
|
||||
static async exportRawPublicKey(keyPair: CryptoKeyPair): Promise<Uint8Array> {
|
||||
return new Uint8Array(
|
||||
await ecdhSubtle().exportKey('raw', keyPair.publicKey)
|
||||
);
|
||||
}
|
||||
|
||||
static buildInit(clientRawPublicKey: Uint8Array): Uint8Array {
|
||||
return concat(
|
||||
new Uint8Array([SSH_MSG_KEX_ECDH_INIT]),
|
||||
encodeString(clientRawPublicKey)
|
||||
);
|
||||
}
|
||||
|
||||
static parseReply(data: Uint8Array): {
|
||||
hostKey: Uint8Array;
|
||||
serverRawPublicKey: Uint8Array;
|
||||
signature: Uint8Array;
|
||||
} {
|
||||
if (data.length < 13 || data[0] !== 31) throw new Error('Malformed ECDH reply');
|
||||
let offset = 1;
|
||||
|
||||
const hostKeyLen = readUint32(data, offset);
|
||||
offset += 4;
|
||||
if (hostKeyLen === 0 || hostKeyLen > data.length - offset) throw new Error('Malformed ECDH host key');
|
||||
const hostKey = data.slice(offset, offset + hostKeyLen);
|
||||
offset += hostKeyLen;
|
||||
|
||||
if (offset + 4 > data.length) throw new Error('Malformed ECDH server key');
|
||||
const qSLen = readUint32(data, offset);
|
||||
offset += 4;
|
||||
if (qSLen === 0 || qSLen > data.length - offset) throw new Error('Malformed ECDH server key');
|
||||
const serverRawPublicKey = data.slice(offset, offset + qSLen);
|
||||
offset += qSLen;
|
||||
|
||||
if (offset + 4 > data.length) throw new Error('Malformed ECDH signature');
|
||||
const sigLen = readUint32(data, offset);
|
||||
offset += 4;
|
||||
if (sigLen === 0 || sigLen !== data.length - offset) throw new Error('Malformed ECDH signature');
|
||||
const signature = data.slice(offset, offset + sigLen);
|
||||
|
||||
return { hostKey, serverRawPublicKey, signature };
|
||||
}
|
||||
|
||||
static async computeSharedSecret(
|
||||
privateKey: CryptoKey,
|
||||
serverRawPublicKey: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
const serverKey = await ecdhSubtle().importKey(
|
||||
'raw',
|
||||
serverRawPublicKey,
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
[]
|
||||
);
|
||||
|
||||
const sharedBits = await ecdhSubtle().deriveBits(
|
||||
{ name: 'ECDH', public: serverKey },
|
||||
privateKey,
|
||||
256
|
||||
);
|
||||
|
||||
const sharedSecret = new Uint8Array(sharedBits);
|
||||
if (isAllZero(sharedSecret)) {
|
||||
throw new Error('ECDH key exchange failed: all-zero shared secret');
|
||||
}
|
||||
|
||||
return toSSHMPInt(sharedSecret);
|
||||
}
|
||||
|
||||
static async computeExchangeHash(
|
||||
clientVersion: string,
|
||||
serverVersion: string,
|
||||
clientKEXInit: Uint8Array,
|
||||
serverKEXInit: Uint8Array,
|
||||
hostKey: Uint8Array,
|
||||
clientRawPublicKey: Uint8Array,
|
||||
serverRawPublicKey: Uint8Array,
|
||||
sharedSecret: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
const v_c = encodeString(clientVersion);
|
||||
const v_s = encodeString(serverVersion);
|
||||
const i_c = encodeString(clientKEXInit);
|
||||
const i_s = encodeString(serverKEXInit);
|
||||
const k_s = encodeString(hostKey);
|
||||
const e = encodeString(clientRawPublicKey);
|
||||
const f = encodeString(serverRawPublicKey);
|
||||
const k = sharedSecret;
|
||||
|
||||
const data = concat(v_c, v_s, i_c, i_s, k_s, e, f, k);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', toBufferSource(data));
|
||||
return new Uint8Array(hashBuffer);
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import { SSH_MSG_KEXINIT, KEXInitMessage } from '../types';
|
||||
import {
|
||||
SUPPORTED_ENCRYPTION_ALGORITHMS,
|
||||
SUPPORTED_KEX_ALGORITHMS,
|
||||
SUPPORTED_MAC_ALGORITHMS
|
||||
} from './algorithms';
|
||||
import { concat } from './utils';
|
||||
|
||||
export class KEXInitBuilder {
|
||||
static build(): Uint8Array {
|
||||
const parts: Uint8Array[] = [];
|
||||
|
||||
parts.push(new Uint8Array([SSH_MSG_KEXINIT]));
|
||||
|
||||
const cookie = new Uint8Array(16);
|
||||
crypto.getRandomValues(cookie);
|
||||
parts.push(cookie);
|
||||
|
||||
const algorithmLists = [
|
||||
['ext-info-c', ...SUPPORTED_KEX_ALGORITHMS].join(','),
|
||||
'ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,rsa-sha2-512,rsa-sha2-256',
|
||||
SUPPORTED_ENCRYPTION_ALGORITHMS.join(','),
|
||||
SUPPORTED_ENCRYPTION_ALGORITHMS.join(','),
|
||||
SUPPORTED_MAC_ALGORITHMS.join(','),
|
||||
SUPPORTED_MAC_ALGORITHMS.join(','),
|
||||
'none',
|
||||
'none',
|
||||
'',
|
||||
'',
|
||||
];
|
||||
|
||||
for (const name of algorithmLists) {
|
||||
const encoded = new TextEncoder().encode(name);
|
||||
const len = new Uint8Array(4);
|
||||
new DataView(len.buffer).setUint32(0, encoded.length, false);
|
||||
parts.push(len);
|
||||
parts.push(encoded);
|
||||
}
|
||||
|
||||
parts.push(new Uint8Array([0]));
|
||||
|
||||
const reserved = new Uint8Array(4);
|
||||
parts.push(reserved);
|
||||
|
||||
return concat(...parts);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseKEXInit(data: Uint8Array): KEXInitMessage {
|
||||
if (data.length < 22 || data[0] !== SSH_MSG_KEXINIT) throw new Error('Malformed KEXINIT header');
|
||||
let offset = 1;
|
||||
|
||||
offset += 16;
|
||||
|
||||
const lists: string[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (offset + 4 > data.length) {
|
||||
throw new Error(`Malformed KEXINIT: truncated length field at list ${i}, offset=${offset}, dataLen=${data.length}`);
|
||||
}
|
||||
const len = (data[offset] << 24) | (data[offset+1] << 16) |
|
||||
(data[offset+2] << 8) | data[offset+3];
|
||||
offset += 4;
|
||||
if (len < 0 || offset + len > data.length) {
|
||||
throw new Error(`Malformed KEXINIT: list ${i} length ${len} exceeds packet boundary (offset=${offset}, dataLen=${data.length})`);
|
||||
}
|
||||
let name: string;
|
||||
try { name = new TextDecoder('utf-8', { fatal: true }).decode(data.subarray(offset, offset + len)); }
|
||||
catch { throw new Error(`Malformed KEXINIT: invalid UTF-8 in list ${i}`); }
|
||||
if (name.includes('\0') || name.split(',').some((entry) => entry.length > 0 && !/^[A-Za-z0-9@._+-]+$/.test(entry))) {
|
||||
throw new Error(`Malformed KEXINIT algorithm list ${i}`);
|
||||
}
|
||||
lists.push(name);
|
||||
offset += len;
|
||||
}
|
||||
|
||||
if (offset + 5 > data.length) throw new Error('Malformed KEXINIT trailer');
|
||||
const firstKexPacketFollows = data[offset] !== 0;
|
||||
offset += 5; // boolean first_kex_packet_follows and uint32 reserved
|
||||
if (offset !== data.length) throw new Error('Malformed KEXINIT trailing data');
|
||||
|
||||
return {
|
||||
kexAlgorithms: lists[0].split(','),
|
||||
hostKeyAlgorithms: lists[1].split(','),
|
||||
encryptionC2S: lists[2].split(','),
|
||||
encryptionS2C: lists[3].split(','),
|
||||
macC2S: lists[4].split(','),
|
||||
macS2C: lists[5].split(','),
|
||||
compressionC2S: lists[6].split(','),
|
||||
compressionS2C: lists[7].split(','),
|
||||
firstKexPacketFollows,
|
||||
};
|
||||
}
|
||||
|
||||
export function negotiate(clientList: string[], serverList: string[], category: string = 'algorithm'): string {
|
||||
for (const algo of clientList) {
|
||||
if (serverList.includes(algo)) return algo;
|
||||
}
|
||||
throw new Error(`No common ${category}: client=[${clientList.join(',')}] server=[${serverList.join(',')}]`);
|
||||
}
|
||||
export function parseServerSigAlgs(payload: Uint8Array): string[] {
|
||||
if (payload[0] !== 7) throw new Error('Malformed EXT_INFO message type');
|
||||
let offset = 1; // Skip the message type.
|
||||
|
||||
if (offset + 4 > payload.length) throw new Error('Malformed EXT_INFO extension count');
|
||||
const nrExtensions = (payload[offset] << 24) | (payload[offset+1] << 16) |
|
||||
(payload[offset+2] << 8) | payload[offset+3];
|
||||
offset += 4;
|
||||
|
||||
if (nrExtensions > 1024) throw new Error(`EXT_INFO extension count is too large (${nrExtensions})`);
|
||||
|
||||
let serverSigAlgs: string[] = [];
|
||||
for (let i = 0; i < nrExtensions; i++) {
|
||||
if (offset + 4 > payload.length) throw new Error('Malformed EXT_INFO extension name length');
|
||||
const nameLen = (payload[offset] << 24) | (payload[offset+1] << 16) |
|
||||
(payload[offset+2] << 8) | payload[offset+3];
|
||||
offset += 4;
|
||||
if (offset + nameLen > payload.length) throw new Error('Malformed EXT_INFO extension name');
|
||||
let name: string;
|
||||
try { name = new TextDecoder('utf-8', { fatal: true }).decode(payload.subarray(offset, offset + nameLen)); }
|
||||
catch { throw new Error('Malformed EXT_INFO extension name'); }
|
||||
offset += nameLen;
|
||||
|
||||
if (offset + 4 > payload.length) throw new Error('Malformed EXT_INFO extension value length');
|
||||
const valueLen = (payload[offset] << 24) | (payload[offset+1] << 16) |
|
||||
(payload[offset+2] << 8) | payload[offset+3];
|
||||
offset += 4;
|
||||
if (offset + valueLen > payload.length) throw new Error('Malformed EXT_INFO extension value');
|
||||
const valueBytes = payload.subarray(offset, offset + valueLen);
|
||||
offset += valueLen;
|
||||
|
||||
if (name === 'server-sig-algs') {
|
||||
let value: string;
|
||||
try { value = new TextDecoder('utf-8', { fatal: true }).decode(valueBytes); }
|
||||
catch { throw new Error('Malformed server-sig-algs extension'); }
|
||||
serverSigAlgs = value.split(',').filter((algorithm) => /^[A-Za-z0-9@._+-]+$/.test(algorithm));
|
||||
}
|
||||
}
|
||||
if (offset !== payload.length) throw new Error('Malformed EXT_INFO trailing data');
|
||||
return serverSigAlgs;
|
||||
}
|
||||
export function filterExtInfo(list: string[]): string[] {
|
||||
return list.filter(a => !a.startsWith('ext-info-'));
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { SessionKeys } from '../types';
|
||||
import { concat, toBufferSource } from './utils';
|
||||
|
||||
export class KeyDerivation {
|
||||
static async deriveKeys(
|
||||
sharedSecret: Uint8Array,
|
||||
exchangeHash: Uint8Array,
|
||||
sessionID: Uint8Array,
|
||||
ivLengthC2S: number = 12,
|
||||
ivLengthS2C: number = 12,
|
||||
integrityKeyLengthC2S: number = 32,
|
||||
integrityKeyLengthS2C: number = 32,
|
||||
encryptionKeyLengthC2S: number = 32,
|
||||
encryptionKeyLengthS2C: number = 32,
|
||||
): Promise<SessionKeys> {
|
||||
const ivC2S = await this.expandKey(sharedSecret, exchangeHash, 'A', sessionID, ivLengthC2S);
|
||||
const ivS2C = await this.expandKey(sharedSecret, exchangeHash, 'B', sessionID, ivLengthS2C);
|
||||
const keyC2S = await this.expandKey(sharedSecret, exchangeHash, 'C', sessionID, encryptionKeyLengthC2S);
|
||||
const keyS2C = await this.expandKey(sharedSecret, exchangeHash, 'D', sessionID, encryptionKeyLengthS2C);
|
||||
const intKeyC2S = await this.expandKey(sharedSecret, exchangeHash, 'E', sessionID, integrityKeyLengthC2S);
|
||||
const intKeyS2C = await this.expandKey(sharedSecret, exchangeHash, 'F', sessionID, integrityKeyLengthS2C);
|
||||
|
||||
return {
|
||||
ivClientToServer: ivC2S,
|
||||
ivServerToClient: ivS2C,
|
||||
encKeyClientToServer: keyC2S,
|
||||
encKeyServerToClient: keyS2C,
|
||||
integrityKeyC2S: intKeyC2S,
|
||||
integrityKeyS2C: intKeyS2C,
|
||||
sessionID,
|
||||
};
|
||||
}
|
||||
|
||||
private static async expandKey(
|
||||
K: Uint8Array,
|
||||
H: Uint8Array,
|
||||
X: string,
|
||||
sessionId: Uint8Array,
|
||||
needed: number
|
||||
): Promise<Uint8Array> {
|
||||
const hashLen = 32;
|
||||
const rounds = Math.ceil(needed / hashLen);
|
||||
const result = new Uint8Array(needed);
|
||||
let offset = 0;
|
||||
|
||||
const XBytes = new TextEncoder().encode(X);
|
||||
|
||||
// K1 = HASH(K || H || X || sessionId)
|
||||
let currentHash = new Uint8Array(
|
||||
await crypto.subtle.digest('SHA-256',
|
||||
toBufferSource(concat(K, H, XBytes, sessionId))
|
||||
)
|
||||
);
|
||||
result.set(currentHash.slice(0, Math.min(hashLen, needed)), 0);
|
||||
offset += hashLen;
|
||||
|
||||
// For Kn (n > 1): Kn = HASH(K || H || K1 || ... || Kn-1)
|
||||
for (let i = 1; i < rounds; i++) {
|
||||
const prevKeys = result.slice(0, offset);
|
||||
currentHash = new Uint8Array(
|
||||
await crypto.subtle.digest('SHA-256',
|
||||
toBufferSource(concat(K, H, prevKeys))
|
||||
)
|
||||
);
|
||||
const remaining = needed - offset;
|
||||
result.set(currentHash.slice(0, Math.min(hashLen, remaining)), offset);
|
||||
offset += hashLen;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { SSHPacket } from '../types';
|
||||
import { readUint32, writeUint32 } from './utils';
|
||||
|
||||
const EMPTY_BUFFER = new Uint8Array(0);
|
||||
const COMPACT_CHUNKS_THRESHOLD = 32;
|
||||
const MAX_PACKET_SIZE = 256 * 1024; // 256KB - RFC 4253 section6.1 requires at least 35000 bytes
|
||||
const MIN_PACKET_LENGTH = 5; // padding_length + at least 4 bytes of padding
|
||||
|
||||
export function nextSequenceNumber(value: number): number {
|
||||
return (value + 1) >>> 0;
|
||||
}
|
||||
|
||||
export class SSHPacketParser {
|
||||
private chunks: Uint8Array[] = [];
|
||||
private chunkIndex: number = 0;
|
||||
private readOffset: number = 0;
|
||||
private bufferedLength: number = 0;
|
||||
private seqNum: number = 0;
|
||||
|
||||
feed(data: Uint8Array): void {
|
||||
if (data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.chunks.push(data);
|
||||
this.bufferedLength += data.length;
|
||||
}
|
||||
|
||||
private peekBytes(bytes: number): Uint8Array | null {
|
||||
if (bytes === 0) {
|
||||
return EMPTY_BUFFER;
|
||||
}
|
||||
|
||||
if (this.bufferedLength < bytes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const first = this.chunks[this.chunkIndex];
|
||||
if (first) {
|
||||
const firstAvailable = first.length - this.readOffset;
|
||||
if (firstAvailable >= bytes) {
|
||||
return first.subarray(this.readOffset, this.readOffset + bytes);
|
||||
}
|
||||
}
|
||||
|
||||
const result = new Uint8Array(bytes);
|
||||
let copied = 0;
|
||||
|
||||
for (let i = this.chunkIndex; copied < bytes; i++) {
|
||||
const chunk = this.chunks[i];
|
||||
const start = i === this.chunkIndex ? this.readOffset : 0;
|
||||
const take = Math.min(chunk.length - start, bytes - copied);
|
||||
result.set(chunk.subarray(start, start + take), copied);
|
||||
copied += take;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private readBytes(bytes: number): Uint8Array | null {
|
||||
const result = this.peekBytes(bytes);
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.consumeBytes(bytes);
|
||||
return result;
|
||||
}
|
||||
|
||||
private consumeBytes(bytes: number): void {
|
||||
if (bytes === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (bytes > this.bufferedLength) {
|
||||
throw new Error('Cannot consume more bytes than buffered');
|
||||
}
|
||||
|
||||
this.bufferedLength -= bytes;
|
||||
|
||||
while (bytes > 0) {
|
||||
const chunk = this.chunks[this.chunkIndex];
|
||||
const available = chunk.length - this.readOffset;
|
||||
|
||||
if (bytes < available) {
|
||||
this.readOffset += bytes;
|
||||
this.compactChunks();
|
||||
return;
|
||||
}
|
||||
|
||||
bytes -= available;
|
||||
this.chunkIndex++;
|
||||
this.readOffset = 0;
|
||||
}
|
||||
|
||||
this.compactChunks();
|
||||
}
|
||||
|
||||
private compactChunks(): void {
|
||||
if (this.bufferedLength === 0) {
|
||||
this.chunks = [];
|
||||
this.chunkIndex = 0;
|
||||
this.readOffset = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Discard fully consumed chunks (Array.slice creates a new reference array,
|
||||
if (
|
||||
this.chunkIndex > COMPACT_CHUNKS_THRESHOLD &&
|
||||
this.chunkIndex * 2 >= this.chunks.length
|
||||
) {
|
||||
this.chunks = this.chunks.slice(this.chunkIndex);
|
||||
this.chunkIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async nextPacket(blockSize: number, decrypt: (
|
||||
data: Uint8Array, seq: number, aad?: Uint8Array, commit?: boolean
|
||||
) => Uint8Array | Promise<Uint8Array | null> | null, hasAuthTag: boolean = false,
|
||||
macLength: number = 0,
|
||||
verifyMac?: (packet: Uint8Array, mac: Uint8Array, seq: number) => boolean | Promise<boolean>): Promise<SSHPacket | null> {
|
||||
if (!Number.isInteger(blockSize) || blockSize < 4) {
|
||||
throw new Error(`Invalid SSH cipher block size ${blockSize}`);
|
||||
}
|
||||
|
||||
if (hasAuthTag) {
|
||||
const lengthBytes = this.peekBytes(4);
|
||||
if (!lengthBytes) return null;
|
||||
|
||||
const packetLength = readUint32(lengthBytes, 0);
|
||||
if (packetLength < MIN_PACKET_LENGTH) throw new Error(`Packet length ${packetLength} is below the SSH minimum`);
|
||||
if (packetLength > MAX_PACKET_SIZE) {
|
||||
throw new Error(`Packet length ${packetLength} exceeds maximum allowed size ${MAX_PACKET_SIZE}`);
|
||||
}
|
||||
// RFC 5647 section 7.2 makes packet_length AAD. The remaining
|
||||
// packetLength bytes are the AES-GCM plaintext and must be block-aligned.
|
||||
if (packetLength % blockSize !== 0) {
|
||||
throw new Error(`Authenticated packet length ${packetLength} is not aligned to block size ${blockSize}`);
|
||||
}
|
||||
const expectedSize = 4 + packetLength + 16;
|
||||
|
||||
if (this.bufferedLength < expectedSize) return null;
|
||||
|
||||
const raw = this.readBytes(expectedSize);
|
||||
if (!raw) return null;
|
||||
|
||||
const lengthField = raw.subarray(0, 4);
|
||||
const dataToDecrypt = raw.subarray(4);
|
||||
const decrypted = await decrypt(dataToDecrypt, this.seqNum, lengthField, true);
|
||||
if (!decrypted) throw new Error('Authenticated packet decryption failed');
|
||||
if (decrypted.length !== packetLength) {
|
||||
throw new Error(`Authenticated packet body length ${decrypted.length} does not match packet length ${packetLength}`);
|
||||
}
|
||||
|
||||
const paddingLength = decrypted[0];
|
||||
if (paddingLength < 4 || paddingLength >= packetLength) {
|
||||
throw new Error(`Invalid padding length ${paddingLength} for packet length ${packetLength}`);
|
||||
}
|
||||
const payload = decrypted.subarray(1, 1 + packetLength - 1 - paddingLength);
|
||||
|
||||
this.seqNum = nextSequenceNumber(this.seqNum);
|
||||
|
||||
return {
|
||||
length: packetLength,
|
||||
paddingLength,
|
||||
payload,
|
||||
mac: raw.subarray(4 + packetLength),
|
||||
};
|
||||
}
|
||||
|
||||
const encryptedHeader = this.peekBytes(blockSize);
|
||||
if (!encryptedHeader) return null;
|
||||
|
||||
const header = await decrypt(
|
||||
encryptedHeader, this.seqNum, undefined, false
|
||||
);
|
||||
if (!header) throw new Error('Packet header decryption failed');
|
||||
|
||||
const packetLength = readUint32(header, 0);
|
||||
if (packetLength < MIN_PACKET_LENGTH) throw new Error(`Packet length ${packetLength} is below the SSH minimum`);
|
||||
if (packetLength > MAX_PACKET_SIZE) {
|
||||
throw new Error(`Packet length ${packetLength} exceeds maximum allowed size ${MAX_PACKET_SIZE}`);
|
||||
}
|
||||
|
||||
const totalSize = 4 + packetLength;
|
||||
if (totalSize % blockSize !== 0) {
|
||||
throw new Error(`Packet size ${totalSize} is not aligned to block size ${blockSize}`);
|
||||
}
|
||||
|
||||
if (this.bufferedLength < totalSize + macLength) return null;
|
||||
|
||||
const encryptedPacket = this.readBytes(totalSize);
|
||||
const mac = this.readBytes(macLength);
|
||||
if (!encryptedPacket || !mac) return null;
|
||||
|
||||
const decrypted = await decrypt(encryptedPacket, this.seqNum, undefined, true);
|
||||
if (!decrypted) throw new Error('Packet decryption failed');
|
||||
if (decrypted.length !== totalSize) {
|
||||
throw new Error(`Decrypted packet size ${decrypted.length} does not match framed size ${totalSize}`);
|
||||
}
|
||||
|
||||
if (verifyMac && macLength > 0) {
|
||||
const macValid = await verifyMac(decrypted, mac, this.seqNum);
|
||||
if (!macValid) {
|
||||
throw new Error('Invalid packet MAC');
|
||||
}
|
||||
}
|
||||
|
||||
const paddingLength = decrypted[4];
|
||||
if (paddingLength < 4 || paddingLength >= packetLength) {
|
||||
throw new Error(`Invalid padding length ${paddingLength} for packet length ${packetLength}`);
|
||||
}
|
||||
const payload = decrypted.subarray(5, 5 + packetLength - 1 - paddingLength);
|
||||
|
||||
this.seqNum = nextSequenceNumber(this.seqNum);
|
||||
|
||||
return {
|
||||
length: packetLength,
|
||||
paddingLength,
|
||||
payload,
|
||||
mac,
|
||||
};
|
||||
}
|
||||
|
||||
getSeqNum(): number {
|
||||
return this.seqNum;
|
||||
}
|
||||
|
||||
getBufferLength(): number {
|
||||
return this.bufferedLength;
|
||||
}
|
||||
}
|
||||
|
||||
export class SSHPacketBuilder {
|
||||
static async build(
|
||||
payload: Uint8Array,
|
||||
blockSize: number,
|
||||
encrypt: ((data: Uint8Array, seq: number, aad?: Uint8Array) => Uint8Array | Promise<Uint8Array>) | null,
|
||||
seqNum: number,
|
||||
hasAuthTag: boolean = false,
|
||||
mac?: (packet: Uint8Array, seq: number) => Uint8Array | Promise<Uint8Array>
|
||||
): Promise<Uint8Array> {
|
||||
return SSHPacketBuilder.buildWithPayloadWriter(
|
||||
payload.length,
|
||||
(packet, offset) => packet.set(payload, offset),
|
||||
blockSize,
|
||||
encrypt,
|
||||
seqNum,
|
||||
hasAuthTag,
|
||||
mac
|
||||
);
|
||||
}
|
||||
|
||||
static async buildWithPayloadWriter(
|
||||
payloadLength: number,
|
||||
writePayload: (packet: Uint8Array, offset: number) => void,
|
||||
blockSize: number,
|
||||
encrypt: ((data: Uint8Array, seq: number, aad?: Uint8Array) => Uint8Array | Promise<Uint8Array>) | null,
|
||||
seqNum: number,
|
||||
hasAuthTag: boolean = false,
|
||||
mac?: (packet: Uint8Array, seq: number) => Uint8Array | Promise<Uint8Array>
|
||||
): Promise<Uint8Array> {
|
||||
const packetLength = 1 + payloadLength;
|
||||
// For AES-GCM (hasAuthTag), padding aligns the encrypted portion
|
||||
// (padding_length + payload + padding) to blockSize.
|
||||
// The 4-byte packet_length is AAD, NOT part of the encrypted data.
|
||||
// For non-GCM, padding aligns the full packet (4 + data) to blockSize.
|
||||
const alignBase = hasAuthTag
|
||||
? (1 + payloadLength) % blockSize // encrypted portion only
|
||||
: (4 + packetLength) % blockSize; // full packet including length
|
||||
const paddingNeeded = blockSize - (alignBase || blockSize);
|
||||
const paddingLength = paddingNeeded < 4
|
||||
? paddingNeeded + blockSize
|
||||
: paddingNeeded;
|
||||
|
||||
const totalLength = 4 + 1 + payloadLength + paddingLength;
|
||||
const packet = new Uint8Array(totalLength);
|
||||
|
||||
const pl = 1 + payloadLength + paddingLength;
|
||||
writeUint32(packet, 0, pl);
|
||||
|
||||
packet[4] = paddingLength;
|
||||
|
||||
writePayload(packet, 5);
|
||||
|
||||
crypto.getRandomValues(packet.subarray(5 + payloadLength));
|
||||
|
||||
if (encrypt) {
|
||||
if (hasAuthTag) {
|
||||
const lengthField = packet.subarray(0, 4);
|
||||
const dataToEncrypt = packet.subarray(4);
|
||||
const encryptedData = await encrypt(dataToEncrypt, seqNum, lengthField);
|
||||
const result = new Uint8Array(4 + encryptedData.length);
|
||||
result.set(lengthField, 0);
|
||||
result.set(encryptedData, 4);
|
||||
return result;
|
||||
}
|
||||
const encryptedPacket = await encrypt(packet, seqNum);
|
||||
if (mac) {
|
||||
const macBytes = await mac(packet, seqNum);
|
||||
const result = new Uint8Array(encryptedPacket.length + macBytes.length);
|
||||
result.set(encryptedPacket, 0);
|
||||
result.set(macBytes, encryptedPacket.length);
|
||||
return result;
|
||||
}
|
||||
return encryptedPacket;
|
||||
}
|
||||
|
||||
return packet;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// RFC 4253 exchange hashes use this exact line without its CRLF terminator.
|
||||
export const SSH_VERSION = 'SSH-2.0-CFWorkersWebSSH_1.0';
|
||||
|
||||
export function isSSH2Identification(value: string): boolean {
|
||||
return value.startsWith('SSH-2.0-') || value.startsWith('SSH-1.99-');
|
||||
}
|
||||
|
||||
export class SSHTransport {
|
||||
private remoteVersion: string = '';
|
||||
private readonly localVersion: string = SSH_VERSION;
|
||||
private versionBuffer: string = '';
|
||||
|
||||
handleVersionExchange(data: string): boolean {
|
||||
this.versionBuffer += data;
|
||||
|
||||
const lines = this.versionBuffer.split('\r\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('SSH-')) {
|
||||
this.remoteVersion = line;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
sendVersion(socket: { write: (data: Uint8Array) => void }): void {
|
||||
const encoder = new TextEncoder();
|
||||
socket.write(encoder.encode(SSH_VERSION + '\r\n'));
|
||||
}
|
||||
|
||||
getRemoteVersion(): string {
|
||||
return this.remoteVersion;
|
||||
}
|
||||
|
||||
getLocalVersion(): string {
|
||||
return this.localVersion;
|
||||
}
|
||||
|
||||
getLocalIdentification(): Uint8Array {
|
||||
return new TextEncoder().encode(`${this.localVersion}\r\n`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
/** Workers WebCrypto currently types BufferSource as ArrayBuffer-backed only. */
|
||||
export function toBufferSource(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
|
||||
export function concat(...arrays: Uint8Array[]): Uint8Array {
|
||||
let totalLength = 0;
|
||||
for (const arr of arrays) {
|
||||
totalLength += arr.length;
|
||||
}
|
||||
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const arr of arrays) {
|
||||
result.set(arr, offset);
|
||||
offset += arr.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function readUint32(data: Uint8Array, offset: number): number {
|
||||
return (
|
||||
(data[offset] << 24) |
|
||||
(data[offset + 1] << 16) |
|
||||
(data[offset + 2] << 8) |
|
||||
data[offset + 3]
|
||||
) >>> 0;
|
||||
}
|
||||
|
||||
export function writeUint32(data: Uint8Array, offset: number, value: number): void {
|
||||
data[offset] = (value >>> 24) & 0xff;
|
||||
data[offset + 1] = (value >>> 16) & 0xff;
|
||||
data[offset + 2] = (value >>> 8) & 0xff;
|
||||
data[offset + 3] = value & 0xff;
|
||||
}
|
||||
|
||||
export function encodeUint32(value: number): Uint8Array {
|
||||
const buf = new Uint8Array(4);
|
||||
writeUint32(buf, 0, value);
|
||||
return buf;
|
||||
}
|
||||
|
||||
export function encodeString(input: string | Uint8Array): Uint8Array {
|
||||
const encoded = typeof input === 'string'
|
||||
? textEncoder.encode(input)
|
||||
: input;
|
||||
const result = new Uint8Array(4 + encoded.length);
|
||||
writeUint32(result, 0, encoded.length);
|
||||
result.set(encoded, 4);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function toSSHMPInt(bytes: Uint8Array): Uint8Array {
|
||||
let start = 0;
|
||||
while (start < bytes.length - 1 && bytes[start] === 0) {
|
||||
start++;
|
||||
}
|
||||
const significant = bytes.subarray(start);
|
||||
const needsLeadingZero = significant.length > 0 && (significant[0] & 0x80) !== 0;
|
||||
const valueOffset = needsLeadingZero ? 5 : 4;
|
||||
const result = new Uint8Array(valueOffset + significant.length);
|
||||
|
||||
writeUint32(result, 0, significant.length + (needsLeadingZero ? 1 : 0));
|
||||
result.set(significant, valueOffset);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** RFC 8731 interprets the X25519 byte string as a network-order integer. */
|
||||
export function x25519SecretToSSHMPInt(bytes: Uint8Array): Uint8Array {
|
||||
if (bytes.length !== 32) throw new Error(`X25519 shared secret must be 32 bytes, got ${bytes.length}`);
|
||||
return toSSHMPInt(bytes);
|
||||
}
|
||||
|
||||
export function extractRawECDHPoint(blob: Uint8Array): Uint8Array {
|
||||
let offset = 0;
|
||||
|
||||
const keyTypeLen = readUint32(blob, offset);
|
||||
offset += 4 + keyTypeLen;
|
||||
|
||||
const curveLen = readUint32(blob, offset);
|
||||
offset += 4 + curveLen;
|
||||
|
||||
const pointLen = readUint32(blob, offset);
|
||||
offset += 4;
|
||||
|
||||
return blob.subarray(offset, offset + pointLen);
|
||||
}
|
||||
|
||||
export function encodePrefixedString(str: string): Uint8Array {
|
||||
return encodeString(str);
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
export interface SSHPacket {
|
||||
length: number;
|
||||
paddingLength: number;
|
||||
payload: Uint8Array;
|
||||
mac?: Uint8Array;
|
||||
}
|
||||
|
||||
export interface KEXInitMessage {
|
||||
kexAlgorithms: string[];
|
||||
hostKeyAlgorithms: string[];
|
||||
encryptionC2S: string[];
|
||||
encryptionS2C: string[];
|
||||
macC2S: string[];
|
||||
macS2C: string[];
|
||||
compressionC2S: string[];
|
||||
compressionS2C: string[];
|
||||
firstKexPacketFollows: boolean;
|
||||
}
|
||||
|
||||
export interface SessionKeys {
|
||||
ivClientToServer: Uint8Array;
|
||||
ivServerToClient: Uint8Array;
|
||||
encKeyClientToServer: Uint8Array;
|
||||
encKeyServerToClient: Uint8Array;
|
||||
integrityKeyC2S: Uint8Array;
|
||||
integrityKeyS2C: Uint8Array;
|
||||
sessionID: Uint8Array;
|
||||
}
|
||||
|
||||
export interface SSHConnectionConfig {
|
||||
type: 'connect';
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
authMethod: 'password' | 'publickey';
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
term: string;
|
||||
expectedFingerprint?: string;
|
||||
}
|
||||
|
||||
export interface TerminalSize {
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export interface Env {
|
||||
SSH_SESSIONS: DurableObjectNamespace;
|
||||
ASSETS?: Fetcher;
|
||||
ACCESS_TOKEN?: string;
|
||||
ALLOW_ANONYMOUS?: string;
|
||||
CONNECT_TIMEOUT_MS?: string;
|
||||
}
|
||||
|
||||
export const SSH_MSG_DISCONNECT = 1;
|
||||
export const SSH_MSG_IGNORE = 2;
|
||||
export const SSH_MSG_UNIMPLEMENTED = 3;
|
||||
export const SSH_MSG_DEBUG = 4;
|
||||
export const SSH_MSG_SERVICE_REQUEST = 5;
|
||||
export const SSH_MSG_SERVICE_ACCEPT = 6;
|
||||
export const SSH_MSG_EXT_INFO = 7;
|
||||
export const SSH_MSG_KEXINIT = 20;
|
||||
export const SSH_MSG_NEWKEYS = 21;
|
||||
export const SSH_MSG_KEX_ECDH_INIT = 30;
|
||||
export const SSH_MSG_KEX_ECDH_REPLY = 31;
|
||||
export const SSH_MSG_USERAUTH_REQUEST = 50;
|
||||
export const SSH_MSG_USERAUTH_FAILURE = 51;
|
||||
export const SSH_MSG_USERAUTH_SUCCESS = 52;
|
||||
export const SSH_MSG_GLOBAL_REQUEST = 80;
|
||||
export const SSH_MSG_REQUEST_SUCCESS = 81;
|
||||
export const SSH_MSG_REQUEST_FAILURE = 82;
|
||||
export const SSH_MSG_CHANNEL_OPEN = 90;
|
||||
export const SSH_MSG_CHANNEL_OPEN_CONFIRMATION = 91;
|
||||
export const SSH_MSG_CHANNEL_OPEN_FAILURE = 92;
|
||||
export const SSH_MSG_CHANNEL_WINDOW_ADJUST = 93;
|
||||
export const SSH_MSG_CHANNEL_DATA = 94;
|
||||
export const SSH_MSG_CHANNEL_EXTENDED_DATA = 95;
|
||||
export const SSH_MSG_CHANNEL_EOF = 96;
|
||||
export const SSH_MSG_CHANNEL_CLOSE = 97;
|
||||
export const SSH_MSG_CHANNEL_REQUEST = 98;
|
||||
export const SSH_MSG_CHANNEL_SUCCESS = 99;
|
||||
export const SSH_MSG_CHANNEL_FAILURE = 100;
|
||||
|
||||
const HOST_RE = /^(?=.{1,253}$)(?:[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?\.)*[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/i;
|
||||
const IPV4_RE = /^(?:\d{1,3}\.){3}\d{1,3}$/;
|
||||
|
||||
export function normalizeTerminalSize(cols: unknown, rows: unknown): TerminalSize | null {
|
||||
if (typeof cols !== 'number' || typeof rows !== 'number' || !Number.isFinite(cols) || !Number.isFinite(rows)) {
|
||||
return null;
|
||||
}
|
||||
const size = { cols: Math.floor(cols), rows: Math.floor(rows) };
|
||||
return size.cols >= 10 && size.cols <= 1000 && size.rows >= 5 && size.rows <= 1000 ? size : null;
|
||||
}
|
||||
|
||||
function isValidHost(host: string): boolean {
|
||||
const bare = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
|
||||
if (bare.includes(':')) {
|
||||
try {
|
||||
return new URL(`http://[${bare}]`).hostname.length > 2;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (/^[\d.]+$/.test(bare)) {
|
||||
if (!IPV4_RE.test(bare)) return false;
|
||||
return bare.split('.').every((part) => Number(part) <= 255 && (part === '0' || !part.startsWith('0')));
|
||||
}
|
||||
return HOST_RE.test(bare);
|
||||
}
|
||||
|
||||
export function parseConnectMessage(value: unknown): SSHConnectionConfig {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid connect message');
|
||||
const raw = value as Record<string, unknown>;
|
||||
if (raw.type !== 'connect') throw new Error('The first message must be type "connect"');
|
||||
if (typeof raw.host !== 'string' || !isValidHost(raw.host.trim())) throw new Error('Invalid SSH host');
|
||||
if (typeof raw.port !== 'number' || !Number.isInteger(raw.port) || raw.port < 1 || raw.port > 65535) throw new Error('Invalid SSH port');
|
||||
if (typeof raw.username !== 'string' || raw.username.length < 1 || raw.username.length > 128 || /[\0\r\n]/.test(raw.username)) throw new Error('Invalid SSH username');
|
||||
if (raw.authMethod !== 'password' && raw.authMethod !== 'publickey') throw new Error('Invalid authentication method');
|
||||
const allowedFields = new Set(['type', 'host', 'port', 'username', 'authMethod', 'password', 'privateKey', 'cols', 'rows', 'term', 'expectedFingerprint']);
|
||||
if (Object.keys(raw).some((field) => !allowedFields.has(field))) throw new Error('Unsupported connection field');
|
||||
const size = normalizeTerminalSize(raw.cols ?? 120, raw.rows ?? 40);
|
||||
if (!size) throw new Error('Invalid terminal size');
|
||||
|
||||
const password = typeof raw.password === 'string' ? raw.password : undefined;
|
||||
const privateKey = typeof raw.privateKey === 'string' ? raw.privateKey : undefined;
|
||||
if (raw.authMethod === 'password' && (password === undefined || password.length > 4096 || privateKey !== undefined)) throw new Error('Password is required and must be the only credential');
|
||||
if (raw.authMethod === 'publickey' && (!privateKey || privateKey.length > 128 * 1024 || !privateKey.includes('BEGIN OPENSSH PRIVATE KEY') || password !== undefined)) throw new Error('An unencrypted OpenSSH private key is required and must be the only credential');
|
||||
if (raw.expectedFingerprint !== undefined && (typeof raw.expectedFingerprint !== 'string' || !/^SHA256:[A-Za-z0-9+/]{43}$/.test(raw.expectedFingerprint))) throw new Error('Invalid host key fingerprint');
|
||||
const term = typeof raw.term === 'string' && /^[A-Za-z0-9._+-]{1,64}$/.test(raw.term) ? raw.term : 'xterm-256color';
|
||||
|
||||
return {
|
||||
type: 'connect', host: raw.host.trim().replace(/^\[|\]$/g, ''), port: raw.port,
|
||||
username: raw.username, authMethod: raw.authMethod, password, privateKey,
|
||||
cols: size.cols, rows: size.rows, term, expectedFingerprint: raw.expectedFingerprint as string | undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Env } from './types';
|
||||
import { SSHSessionDO } from './backend/durable-object';
|
||||
import { constantTimeEqual, isSameOrigin } from './backend/security';
|
||||
import { httpsRedirect, isProductionHttp, jsonError, secureResponse } from './http-security';
|
||||
|
||||
export { SSHSessionDO };
|
||||
|
||||
function clientAddress(request: Request): string {
|
||||
const value = request.headers.get('CF-Connecting-IP') ?? 'local';
|
||||
return /^[0-9A-Fa-f:.]{2,64}$/.test(value) ? value.toLowerCase() : 'unknown';
|
||||
}
|
||||
|
||||
async function sessionTicket(request: Request, env: Env): Promise<Response> {
|
||||
if (!isSameOrigin(request)) return jsonError('Invalid request origin', 403);
|
||||
if (!request.headers.get('Content-Type')?.toLowerCase().startsWith('application/json')) return jsonError('Expected application/json', 415);
|
||||
const contentLength = Number(request.headers.get('Content-Length') ?? 0);
|
||||
if (!Number.isFinite(contentLength) || contentLength < 0 || contentLength > 8192) return jsonError('Request body is too large', 413);
|
||||
let body: unknown;
|
||||
try {
|
||||
const text = await request.text();
|
||||
if (new TextEncoder().encode(text).length > 8192) return jsonError('Request body is too large', 413);
|
||||
body = JSON.parse(text);
|
||||
} catch { return jsonError('Invalid JSON body', 400); }
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) return jsonError('Invalid JSON body', 400);
|
||||
const fields = Object.keys(body as Record<string, unknown>);
|
||||
if (fields.some((field) => field !== 'accessToken')) return jsonError('Unsupported request field', 400);
|
||||
if (!env.ACCESS_TOKEN && env.ALLOW_ANONYMOUS !== 'true') {
|
||||
return jsonError('Gateway access is not configured', 503);
|
||||
}
|
||||
const supplied = (body as Record<string, unknown>).accessToken;
|
||||
if (typeof supplied === 'string' && supplied.length > 4096) return jsonError('Access token is too large', 413);
|
||||
if (env.ACCESS_TOKEN && (typeof supplied !== 'string' || !constantTimeEqual(supplied, env.ACCESS_TOKEN))) return jsonError('Invalid access token', 401);
|
||||
const id = env.SSH_SESSIONS.newUniqueId();
|
||||
const stub = env.SSH_SESSIONS.get(id);
|
||||
const response = await stub.fetch(new Request('https://session.internal/ticket', {
|
||||
method: 'POST',
|
||||
headers: { 'x-client-ip': clientAddress(request) },
|
||||
}));
|
||||
if (!response.ok) return jsonError('Unable to create a session ticket', 503);
|
||||
const ticket = await response.json<{ ticket: string; expiresAt: number }>();
|
||||
return secureResponse(Response.json({ ...ticket, sessionId: id.toString() }, { headers: { 'Cache-Control': 'no-store' } }));
|
||||
}
|
||||
|
||||
async function sshUpgrade(request: Request, env: Env): Promise<Response> {
|
||||
if (!isSameOrigin(request)) return jsonError('Invalid WebSocket origin', 403);
|
||||
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') return jsonError('WebSocket upgrade required', 426);
|
||||
const url = new URL(request.url);
|
||||
const ticket = url.searchParams.get('ticket');
|
||||
const sessionId = url.searchParams.get('session');
|
||||
if (!ticket || !sessionId) return jsonError('Missing session ticket', 401);
|
||||
let id: DurableObjectId;
|
||||
try { id = env.SSH_SESSIONS.idFromString(sessionId); } catch { return jsonError('Invalid session identifier', 401); }
|
||||
const headers = new Headers(request.headers);
|
||||
headers.delete('Cookie');
|
||||
headers.delete('Authorization');
|
||||
headers.set('x-session-ticket', ticket);
|
||||
headers.set('x-client-ip', clientAddress(request));
|
||||
const response = await env.SSH_SESSIONS.get(id).fetch(new Request('https://session.internal/connect', { headers }));
|
||||
if (response.status === 401) return jsonError('Invalid, expired, or already used session ticket', 401);
|
||||
return response;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
if (isProductionHttp(request)) {
|
||||
if (url.pathname.startsWith('/api/')) return jsonError('HTTPS is required', 403);
|
||||
if (request.method === 'GET' || request.method === 'HEAD') return httpsRedirect(request);
|
||||
return jsonError('HTTPS is required', 403);
|
||||
}
|
||||
if (url.pathname === '/api/health' && request.method === 'GET') {
|
||||
return secureResponse(Response.json({ status: 'ok', runtime: 'cloudflare-workers', ssh: true }, { headers: { 'Cache-Control': 'no-store' } }));
|
||||
}
|
||||
if (url.pathname === '/api/session') {
|
||||
if (request.method !== 'POST') return jsonError('Method not allowed', 405);
|
||||
return sessionTicket(request, env);
|
||||
}
|
||||
if (url.pathname === '/api/ssh') {
|
||||
if (request.method !== 'GET') return jsonError('Method not allowed', 405);
|
||||
return sshUpgrade(request, env);
|
||||
}
|
||||
if (url.pathname.startsWith('/api/')) return jsonError('Not found', 404);
|
||||
if (!env.ASSETS) return jsonError('Static assets binding is not configured', 503);
|
||||
return secureResponse(await env.ASSETS.fetch(request));
|
||||
} catch (error) {
|
||||
console.error('Worker request failed', error instanceof Error ? error.message : String(error));
|
||||
return jsonError('Internal server error', 500);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["frontend/src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "WebWorker"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
root: 'frontend',
|
||||
base: '/',
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8787',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
esbuild: {
|
||||
// xterm 6 is already optimized; syntax minification can break DECRQM handling.
|
||||
minifySyntax: false,
|
||||
},
|
||||
build: {
|
||||
outDir: '../dist',
|
||||
emptyOutDir: true,
|
||||
sourcemap: false,
|
||||
modulePreload: { polyfill: false },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
coverage: {
|
||||
reporter: ['text', 'html'],
|
||||
include: ['src/ssh/**/*.ts', 'src/backend/security.ts', 'src/types.ts'],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { cloudflareTest } from '@cloudflare/vitest-pool-workers';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [cloudflareTest({ wrangler: { configPath: './wrangler.toml' } })],
|
||||
test: {
|
||||
include: ['tests-edge/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
name = "cf-workers-webssh"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2026-07-01"
|
||||
workers_dev = true
|
||||
|
||||
[assets]
|
||||
directory = "./dist"
|
||||
binding = "ASSETS"
|
||||
not_found_handling = "single-page-application"
|
||||
run_worker_first = true
|
||||
|
||||
[vars]
|
||||
ALLOW_ANONYMOUS = "false"
|
||||
CONNECT_TIMEOUT_MS = "10000"
|
||||
|
||||
[[durable_objects.bindings]]
|
||||
name = "SSH_SESSIONS"
|
||||
class_name = "SSHSessionDO"
|
||||
|
||||
[[migrations]]
|
||||
tag = "v1"
|
||||
new_sqlite_classes = ["SSHSessionDO"]
|
||||
|
||||
# Recommended production gate. Without it, sessions stay disabled unless
|
||||
# ALLOW_ANONYMOUS is explicitly changed to "true". Never store it here:
|
||||
# npx wrangler secret put ACCESS_TOKEN
|
||||
Reference in new issue
Block a user