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:
cmliu committed 2026-07-27 19:05:23 +08:00
1 parent 17a6027bf4
commit 623d270eec
33 files changed
+9997 -1

No files matched your search

+4
View File
@@ -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
View File
@@ -0,0 +1,10 @@
node_modules/
dist/
.wrangler/
coverage/
tests/
tests-edge/
*.log
.dev.vars
.env*
!.env.example
+201
View File
@@ -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.
+492 -1
View File
@@ -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)。
+239
View File
@@ -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">&gt;_</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">&gt;_</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">&gt;_</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 &amp; continue</button>
</div>
</form>
</dialog>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+5
View File
@@ -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

+1154
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+2594
View File
File diff suppressed because it is too large. Load diff
+33
View File
@@ -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"
}
}
+201
View File
@@ -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 */ }
}
}
+159
View File
@@ -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;
}
+762
View File
@@ -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();
}
}
+29
View File
@@ -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' },
}));
}
+68
View File
@@ -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
View File
@@ -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;
}
}
+325
View File
@@ -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`);
}
}
+199
View File
@@ -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;
}
+146
View File
@@ -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);
}
}
+137
View File
@@ -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
View File
@@ -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-'));
}
+72
View File
@@ -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;
}
}
+311
View File
@@ -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;
}
}
+43
View File
@@ -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`);
}
}
+94
View File
@@ -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
View File
@@ -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,
};
}
+91
View File
@@ -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);
}
},
};
+14
View File
@@ -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"]
}
+15
View File
@@ -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"]
}
+25
View File
@@ -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 },
},
});
+12
View File
@@ -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'],
},
},
});
+9
View File
@@ -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'],
},
});
+26
View File
@@ -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