mirror of
https://github.com/LamGC/quickly-conf-sshd.git
synced 2026-10-13 07:43:57 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1792af9828 | ||
|
|
b6b0f3bb3d | ||
|
|
d72aa6eda7 | ||
|
|
2f2f6db4d6 | ||
|
|
be1e50fd54 | ||
|
|
5798bf8900 |
@@ -0,0 +1,70 @@
|
|||||||
|
name: Validate repository keys and clients
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- name: Install shell test interpreters
|
||||||
|
run: |
|
||||||
|
if ! command -v busybox >/dev/null || ! command -v shellcheck >/dev/null || ! command -v gpg >/dev/null; then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y busybox-static shellcheck gnupg
|
||||||
|
fi
|
||||||
|
busybox sh -c 'echo "BusyBox shell ready"'
|
||||||
|
shellcheck --version
|
||||||
|
gpg --version
|
||||||
|
- name: Run unit, shell, and real-browser tests
|
||||||
|
run: npm test
|
||||||
|
env:
|
||||||
|
BROWSER_TESTS: '1'
|
||||||
|
BROWSER_ARTIFACTS_DIR: /tmp/quickly-browser-artifacts
|
||||||
|
- name: Build isolated test fixture (never deployed)
|
||||||
|
run: npm run build:test
|
||||||
|
- name: Validate Worker bundle without publishing
|
||||||
|
run: npx wrangler deploy --dry-run --config cf-worker/wrangler.toml --outdir /tmp/worker-dry-run
|
||||||
|
- name: Upload browser evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
|
with:
|
||||||
|
name: browser-evidence
|
||||||
|
path: /tmp/quickly-browser-artifacts
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
|
live-discovery:
|
||||||
|
name: Live public GPG discovery (external network)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 6
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
- name: Ensure actual GnuPG network dependencies
|
||||||
|
run: |
|
||||||
|
if ! command -v gpg >/dev/null || ! command -v dirmngr >/dev/null; then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y gnupg dirmngr
|
||||||
|
fi
|
||||||
|
gpg --version
|
||||||
|
dirmngr --version
|
||||||
|
- name: Discover the fixed public test certificate through WKD and HKPS
|
||||||
|
# This fixture is ONLY a public integration-test identity. Fork owners
|
||||||
|
# must not replace it with their deployment identity or production keys.
|
||||||
|
# No installer, system trust changes, signing, or SSH configuration runs.
|
||||||
|
run: node scripts/test-live-discovery.mjs
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
name: Publish server key release
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
concurrency:
|
||||||
|
group: production-key-release
|
||||||
|
cancel-in-progress: false
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
# Repository Actions variables, not production environment variables: this
|
||||||
|
# job deliberately has no production environment or production secrets.
|
||||||
|
# The workflow/verifier themselves must be protected against modification.
|
||||||
|
if: github.ref == 'refs/heads/main' && vars.ENABLE_WORKER_DEPLOY == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
ref: ${{ github.sha }}
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Check exact checkout and optional pinned GPG commit signature
|
||||||
|
run: node scripts/verify-commit-signature.mjs
|
||||||
|
env:
|
||||||
|
REQUIRE_GPG_SIGNATURE: ${{ vars.REQUIRE_GPG_SIGNATURE }}
|
||||||
|
GPG_ALLOWED_PRIMARY_FINGERPRINTS: ${{ vars.GPG_ALLOWED_PRIMARY_FINGERPRINTS }}
|
||||||
|
GPG_PUBLIC_KEYS: ${{ vars.GPG_PUBLIC_KEYS }}
|
||||||
|
deploy:
|
||||||
|
needs: verify
|
||||||
|
# Deliberate opt-in prevents accidental deployment when this PR is merged.
|
||||||
|
# Both push and workflow_dispatch must pass the same verification job.
|
||||||
|
if: github.ref == 'refs/heads/main' && vars.ENABLE_WORKER_DEPLOY == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
environment: production
|
||||||
|
outputs:
|
||||||
|
attempted: ${{ steps.deploy.outputs.attempted }}
|
||||||
|
deployment_outcome: ${{ steps.deploy.outcome }}
|
||||||
|
verification_outcome: ${{ steps.remote.outcome }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
ref: ${{ github.sha }}
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- name: Install shell test interpreters
|
||||||
|
run: |
|
||||||
|
if ! command -v busybox >/dev/null || ! command -v shellcheck >/dev/null || ! command -v gpg >/dev/null; then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y busybox-static shellcheck gnupg
|
||||||
|
fi
|
||||||
|
busybox sh -c 'echo "BusyBox shell ready"'
|
||||||
|
shellcheck --version
|
||||||
|
gpg --version
|
||||||
|
- run: npm test
|
||||||
|
env:
|
||||||
|
BROWSER_TESTS: '1'
|
||||||
|
- name: Build production release (empty keys fail unless explicitly allowed)
|
||||||
|
run: npm run build
|
||||||
|
- run: node scripts/verify-release.mjs
|
||||||
|
- name: Deploy the validated production bundle
|
||||||
|
id: deploy
|
||||||
|
run: |
|
||||||
|
echo 'attempted=true' >> "$GITHUB_OUTPUT"
|
||||||
|
npx wrangler deploy --config cf-worker/wrangler.toml
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
- name: Verify published revision and public key payload
|
||||||
|
id: remote
|
||||||
|
run: node scripts/verify-release.mjs --remote
|
||||||
|
notify:
|
||||||
|
needs: [verify, deploy]
|
||||||
|
# Runs only after Wrangler was attempted, including deployment/remote-check
|
||||||
|
# failure. Gist edits alone do not trigger this workflow or an email.
|
||||||
|
if: always() && needs.verify.result == 'success' && needs.deploy.outputs.attempted == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 3
|
||||||
|
environment: production
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
ref: ${{ github.sha }}
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Send optional deployment email-provider webhook
|
||||||
|
run: node scripts/notify-deployment.mjs
|
||||||
|
env:
|
||||||
|
ENABLE_MAIL_NOTIFICATION: ${{ vars.ENABLE_MAIL_NOTIFICATION }}
|
||||||
|
MAIL_NOTIFICATION_WEBHOOK: ${{ secrets.MAIL_NOTIFICATION_WEBHOOK }}
|
||||||
|
MAIL_NOTIFICATION_TO: ${{ vars.MAIL_NOTIFICATION_TO }}
|
||||||
|
DEPLOYMENT_STATUS: ${{ needs.deploy.result }}
|
||||||
|
DEPLOYMENT_PHASE: ${{ needs.deploy.outputs.deployment_outcome != 'success' && 'deployment' || needs.deploy.outputs.verification_outcome != 'success' && 'remote-verification' || 'complete' }}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
name: Update access url
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update-url:
|
|
||||||
timeout-minutes: 5
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
- name: Update access url
|
|
||||||
run: |
|
|
||||||
sed -i "s/ssh.lamgc.me/$REPLACEMENT_VALUE/g" README.md
|
|
||||||
env:
|
|
||||||
# 要替换的个人访问地址,例如:ssh.lamgc.me
|
|
||||||
REPLACEMENT_VALUE: ${{ secrets.ACCESS_URL }}
|
|
||||||
- name: Commit changes
|
|
||||||
uses: stefanzweifel/git-auto-commit-action@v4
|
|
||||||
with:
|
|
||||||
commit_message: Update access url to ${{ secrets.ACCESS_URL }}
|
|
||||||
commit_options: '--no-verify'
|
|
||||||
file_pattern: README.md
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
cf-worker/generated/
|
||||||
|
cf-worker/public/
|
||||||
|
.wrangler/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
@@ -1,98 +1,285 @@
|
|||||||
# Quickly Configuring SSH Server
|
# Quickly Configuring SSH Server
|
||||||
快速地将 SSH 密钥部署到服务器中,并定期更新。
|
|
||||||
|
|
||||||
这个脚本可以帮你做到这些事情:
|
在 Git 仓库或指定账号的 Gist 中独立管理服务器 SSH 公钥,通过同一个 Cloudflare Worker 发布网页、脚本、公钥清单、签名和公钥证书。
|
||||||
- 快速配置服务器的 SSH Server;
|
|
||||||
- 定期自动更新 SSH 公钥到服务器中;
|
|
||||||
- 集中管理注册到服务器的 SSH 公钥(例如使用 Github 管理 SSH 公钥);
|
|
||||||
|
|
||||||
## Quick Usage
|
- 不默认读取 GitHub 账号的 SSH 登录钥匙,不依赖独立 GitHub Pages 部署。
|
||||||
部署密钥并设置定期更新:
|
- 普通模式仅校验公钥格式;可选高信任模式由**服务器脚本**验证 GPG 签名,Worker 无法远程关闭它。
|
||||||
```bash
|
- 脚本落盘后固定,定时任务只同步公钥。升级、修改信任指纹或关闭验签需要显式重新安装。
|
||||||
curl -s https://ssh.lamgc.me | bash -s -- -c
|
- 首页有浅色 / 深色 / 系统主题、响应式布局和逐条命令复制;不显示或内嵌公钥正文、指纹、个人注释。
|
||||||
```
|
|
||||||
如果不希望自动更新密钥,可以移除 `-c` 参数:
|
> 初始生产清单和备用源为空,没有配置实际签名证书或发布凭据。生产构建不会偷偷使用测试密钥。仓库中的公钥文件不是 GitHub 账号登录凭据。
|
||||||
```bash
|
|
||||||
curl -s https://ssh.lamgc.me | bash -s
|
## Quick Usage 与重新安装
|
||||||
```
|
|
||||||
跳过检查 SSH Server,只需要安装秘钥的话,就需要单独使用 `-o` 参数:
|
先配置并部署自己的 Worker,再使用自己的域名。以下地址只是本项目的部署配置示例。
|
||||||
```bash
|
|
||||||
curl -s https://ssh.lamgc.me | bash -s -- -o
|
普通 HTTPS 安装 / 显式重新安装并启用定时同步:
|
||||||
```
|
|
||||||
添加 `-p yes` 参数可以设置允许 Root 用户使用密码登录 SSH(需使用 Root 用户执行脚本):
|
```sh
|
||||||
```bash
|
curl -fsSL 'https://ssh.lamgc.me/script.sh' | sh -s -- --install --download-script -c
|
||||||
curl -s https://ssh.lamgc.me | bash -s -- -p yes
|
|
||||||
```
|
|
||||||
要查看脚本的帮助信息,请使用 `-h` 参数:
|
|
||||||
```bash
|
|
||||||
curl -s https://ssh.lamgc.me | bash -s -- -h
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> Note: 更多用法请参见脚本帮助信息~
|
再次执行安装命令会检测已安装脚本,备份并升级。签名策略只在已替换旧客户端之后激活;若后续磁盘写入失败,可能留下新脚本和未完成的配置,请按错误信息检查并显式重新安装,不要把失败当作策略已启用。已有本地配置和自定义定时计划默认保留,不因远端仓库默认值变化而重置。新安装默认每 15 分钟同步。
|
||||||
|
|
||||||
## Paths
|
**这条命令信任下载到的可执行脚本。** HTTPS、同一 Worker 提供的摘要和后续公钥验签,不能证明首次安装器本身可信。高信任安装应先取得独立渠道核对过的脚本摘要和完整 GPG 指纹,下载、核验本地文件后执行安装。不要把从同一被攻陷来源获得的指纹当作独立信任依据。
|
||||||
获取 SSH 公钥:
|
|
||||||
```
|
原版 Quick Usage 的一次部署、只安装钥匙、root 策略与帮助入口继续保留。普通运行会按权限检查 SSH Server;root 缺少服务端时可按需安装,非 root 不安装系统包。`-n` 跳过服务端检查/安装,`-o` 只处理当前用户的钥匙,不配置 SSH 服务。
|
||||||
https://ssh.lamgc.me/ssh.keys
|
|
||||||
|
一次部署,不设置定时任务:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -fsSL 'https://ssh.lamgc.me/' | sh -s
|
||||||
```
|
```
|
||||||
|
|
||||||
获取 Shell 脚本:
|
跳过 SSH Server 检查,只安装钥匙:
|
||||||
```
|
|
||||||
https://ssh.lamgc.me/script.sh
|
```sh
|
||||||
|
curl -fsSL 'https://ssh.lamgc.me/' | sh -s -- -o
|
||||||
```
|
```
|
||||||
|
|
||||||
获取 Shell 脚本(使用 Curl 访问):
|
显式设置 root 登录策略并部署钥匙(必须以 root 执行,可再加 `-c`):
|
||||||
```
|
|
||||||
https://ssh.lamgc.me/
|
```sh
|
||||||
|
curl -fsSL 'https://ssh.lamgc.me/' | sh -s -- -p yes
|
||||||
```
|
```
|
||||||
|
|
||||||
访问本页面(不使用 Curl 访问):
|
`-p yes` 仅设置 `PermitRootLogin yes`,不额外打开 `PasswordAuthentication`。使用 `-p no` 设置 `prohibit-password`。会先验证钥匙、备份和校验配置;不会自动重载 SSH 服务,请保留恢复会话并按输出手动检查。
|
||||||
```
|
|
||||||
https://ssh.lamgc.me/
|
日常同步、预览、状态使用已经落盘的脚本:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh "$HOME/.conf-sshd/conf-sshd.sh" -o
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install
|
```sh
|
||||||
### Configuration
|
sh "$HOME/.conf-sshd/conf-sshd.sh" -o --dry-run
|
||||||
先 Fork 本仓库,Fork 后在仓库设置中启用 Github Pages,分支指向 main 分支,目录为仓库根目录。
|
|
||||||
配置好后将仓库克隆到本地,然后修改 `cf-worker/src/index.js` 中的前五个变量:
|
|
||||||
```javascript
|
|
||||||
// 改成你自己的 Github 用户名,注意是登录 Github 的那个用户名.
|
|
||||||
const githubUserName = "LamGC";
|
|
||||||
// 改成你 Fork 后的仓库名,记得要开启 Github Pages 功能.
|
|
||||||
const githubInstSshProjectName = "quickly-conf-sshd";
|
|
||||||
// 如果可以,建议在此设置备用的 SSH 公钥, 以防 Github 无法使用.
|
|
||||||
const backupSshKeys = ``;
|
|
||||||
// Worker 的访问地址, 如果不填的话默认为请求的地址, 填了就会用这里的地址(要去 Worker 的触发器那绑定, 否则无效).
|
|
||||||
const defaultBaseUrl = "";
|
|
||||||
// Cron 表达式, 默认 1 天执行一次更新.
|
|
||||||
const cronExpression = "0 0 * * *";
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Use Wrangler
|
```sh
|
||||||
如果你安装了 Cloudflare 的 Wrangler,可以直接在 `cf-worker` 目录中执行:
|
sh "$HOME/.conf-sshd/conf-sshd.sh" --status
|
||||||
```bash
|
|
||||||
wrangler publish
|
|
||||||
```
|
```
|
||||||
执行后将会自动创建 Worker 并上传 worker js 代码,Worker 的名字是 `quickly-conf-ssh-worker`。
|
|
||||||
|
|
||||||
### Manual Install
|
查看完整参数,不修改配置:
|
||||||
如果你没有安装 Wrangler,那就手动在 Cloudflare Workers 中新建一个 Worker,名字你喜欢就好(也可以使用 `quickly-conf-ssh-worker`),选择 HTTP 处理程序,然后下一步;
|
|
||||||
|
|
||||||

|
```sh
|
||||||
|
curl -fsSL 'https://ssh.lamgc.me/script.sh' | sh -s -- -h
|
||||||
|
```
|
||||||
|
|
||||||
创建完成后点击 Worker 名称右边的**快速编辑**,将 `cf-worker/src/index.js` 中的内容复制到编辑框中,把原本的代码覆盖掉,然后保存;
|
指定 / 取消自己的定时计划:
|
||||||

|
|
||||||
|
|
||||||
保存后需要访问一下 Worker,检查是否有误(如果不检查仔细,把其他人的 SSH 密钥设置到服务器里了,那就寄咯):
|
```sh
|
||||||
- 直接访问 Worker 地址,看看是否能跳转到你 Fork 的仓库中(地址栏显示了 `https://{你的 Github 用户名}.github.io/{Fork的仓库名}/`);
|
sh "$HOME/.conf-sshd/conf-sshd.sh" -c '0 */6 * * *'
|
||||||
- 访问 `{Worker地址}/ssh.keys`,应该显示你的在 Github 设置了的所有 SSH 公钥,如果是空的或者不是你的密钥,那就检查一下是不是 Github 用户名填错了;
|
```
|
||||||
- 访问 `{Worker地址}/script.sh`,应该返回一个脚本,脚本开头的配置应与你在 Worker 开头的有关配置一致。
|
|
||||||
|
|
||||||
检查无误后就算安装完成了,然后修改本 Readme 的上面几个部分,把域名都改成自己的,保存后提交 Push 上去就好。
|
```sh
|
||||||
(如果你不会用 Git,那就直接去 Github 网页上改 Readme.md)
|
sh "$HOME/.conf-sshd/conf-sshd.sh" -c false
|
||||||
|
```
|
||||||
|
|
||||||
### (可选)使用自定义域名
|
```sh
|
||||||
在 Worker 详情中,点击“触发器”,然后点击“添加自定义域”,按照提示输入已添加在 Cloudflare 中的域名即可。
|
sh "$HOME/.conf-sshd/conf-sshd.sh" --uninstall
|
||||||

|
```
|
||||||

|
|
||||||

|
卸载保留授权密钥、SSH 服务配置、本地信任配置与防回滚状态。`-u` 不再下载或替换脚本,会提示显式重新安装。没有自动更新周期;延长更新周期本身也不是安全验证。
|
||||||
|
|
||||||
|
## Git / Gist 管理模式
|
||||||
|
|
||||||
|
### Git
|
||||||
|
|
||||||
|
`keySource.type` 为 `git`(默认)。普通公钥逐行放入 `keys/authorized_keys`;构建校验并去掉所有注释,再发布。若注释曾提交公开仓库,它仍可能存在于历史中,因此发布前也应从源文件去掉个人信息。
|
||||||
|
|
||||||
|
支持 ED25519、RSA(2048–16384 位)、ECDSA nistp256/384/521 和 OpenSSH 安全密钥格式。不接受 DSA、SSH 证书、`command=` 等 authorized_keys 选项、重复公钥或私钥。
|
||||||
|
|
||||||
|
签名发布使用精确签名文档和 detached ASCII-armored GPG 签名。两者与网页、脚本一同构建,作为同一 Worker 发布产物交付;不会在发布后重新生成签名正文。
|
||||||
|
|
||||||
|
在持有签名私钥的可信电脑上,先生成新的文档(序号必须递增,时间自行选择):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node scripts/prepare-key-release.mjs --input keys/authorized_keys --output keys/ssh.keys --list-id my-servers --sequence 1 --issued-at 1791856800 --expires-at 1794448800
|
||||||
|
```
|
||||||
|
|
||||||
|
上例时间仅展示参数格式,不应照抄为正式有效期。工具会拒绝覆盖已有文件;更新时先保留旧版并明确选择新输出,再审阅替换。清空发布必须使用空输入并加 `--revoke`。
|
||||||
|
|
||||||
|
审核精确文档后,使用你自己的授权签名钥匙手动签名:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gpg --armor --detach-sign --local-user FULL_PRIMARY_FINGERPRINT --output keys/ssh.keys.sig keys/ssh.keys
|
||||||
|
```
|
||||||
|
|
||||||
|
`FULL_PRIMARY_FINGERPRINT` 必须替换为独立核对的完整指纹;GPG 可选择该主钥的签名子钥。不要上传私钥,也不要在 Actions 中自动运行签名步骤。上传的 `ssh.keys` 与 `ssh.keys.sig` 必须保持原始字节;Gist 使用同样文件即可。Git 也可使用工具打包的 `keys/release.bundle`,但不能与 `keys/ssh.keys` 同时存在。
|
||||||
|
|
||||||
|
|
||||||
|
### Gist
|
||||||
|
|
||||||
|
配置固定的 Gist ID、账号数字 `ownerId`、显示用 `ownerLogin` 和文件名。Worker 通过 GitHub REST API 核对所属账号,取得当前修订后,再从那个固定修订读取公钥与签名。不能仅靠 URL 中的用户名判断所属,也不能从任意请求参数传入 Gist/上游地址。
|
||||||
|
|
||||||
|
公开 Gist 无需读取 token;仅使用明确配置的文件,不跟随 API 返回的任意 raw URL。缺失文件、截断响应、owner 不匹配、超时或非 200 均视为来源失败。
|
||||||
|
|
||||||
|
同一 Gist 同时含 `release.bundle` 与拆分文件时,bundle 优先;切换到拆分文件前应明确移除旧 bundle,避免持续发布旧内容。`ownerLogin` 只是显示信息,数字 owner ID 才是身份固定值。
|
||||||
|
|
||||||
|
更新签名文档后必须重新签名。Worker 不持有签名私钥,不会为 Gist 的任意修改自动背书。普通模式可以发布无签名清单;客户端是否验签由其本地策略决定。
|
||||||
|
|
||||||
|
Gist 配置示例(将 ID 与账号数字 ID 替换为经过核对的真实值):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"keySource":{"type":"gist","id":"0123456789abcdef0123456789abcdef","ownerId":123456,"ownerLogin":"example","file":"ssh.keys","signatureFile":"ssh.keys.sig","bundleFile":"release.bundle"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 可见性
|
||||||
|
|
||||||
|
网页不嵌入密钥,不代表数据保密。`/ssh.keys` 可公开读取,公开 Git/Gist 和历史也可访问;去掉注释后,相同公钥字节仍能被关联。私钥始终留在使用者或可信签名端,不能上传本项目、Worker、Actions 或 Gist。
|
||||||
|
|
||||||
|
## 客户端 GPG 验证(默认 OFF)
|
||||||
|
|
||||||
|
- **OFF**:只下载 `/ssh.keys`,校验协议、公钥格式和明确空清单;不请求 `/ssh.keys.sig` 或 `/public-key.gpg`,即使它们存在也不验签。
|
||||||
|
- **ON**:取得 `/ssh.keys` 原始字节和同源 `/ssh.keys.sig`,用安装时固定的专用本地钥匙环验证。HTTP 下载每次都必须是 200;404、其他状态、重定向、下载失败、证书指纹不符或验签失败均拒绝该更新。绝不自动回退为 OFF。
|
||||||
|
- 验签覆盖完整响应,包括元数据、换行和公钥正文。不能先去注释、重新排序或转换编码再验签。
|
||||||
|
- 本地记录完整 40 位 OpenPGP v4 指纹,不接受短 key ID。`primary` 模式接受该主密钥及经 GPG 验证绑定的有效签名子钥;`subkey` 只接受指定签名子钥,不接受同主钥的其他子钥。主钥下后来新增的签名子钥仍需通过显式重新安装更新本地证书;固定指纹不会自动下载最新子钥集合。
|
||||||
|
- 日常验证使用隔离的专用本地 GPG keyring,不信任用户默认钥匙环,也不自动访问 keyserver;网络返回的其他钥匙不能扩大本地授权。
|
||||||
|
|
||||||
|
仓库可设置新安装默认值,安装时可显式覆盖。**已安装机器的本地配置优先**,之后改仓库默认值不能关闭它的验证。开启验证、换指纹/信任范围或关闭已有验证都必须显示具体信息并取得本地明确确认;取消或无法确认时不应用修改。
|
||||||
|
|
||||||
|
安装在本地明确确认后依序寻找对应证书:已有本地证书、Worker `/public-key.gpg`、可选 Email 的 WKD、配置的有序 keyserver。每一步仍必须匹配完整指纹;没有 Email 时跳过 WKD;内置有序服务器为 `hkps://keys.openpgp.org`、`hkps://keyserver.ubuntu.com`,允许显式覆盖。它们只是证书分发源,不是信任根;缺少 UID、无法导入或不支持指定子钥查询时继续下一源,绝不缩短指纹。全部失败则保留现有配置并停止。通过验证的公共证书保存到专用本地钥匙环。仓库默认 Email 会写入公开安装脚本,只应填写愿意公开的邮箱;它只是发现提示,不能取代指纹。
|
||||||
|
|
||||||
|
公钥证书可公开分发,信任来自独立核对并固定的完整指纹。证书内容即使具有相同主指纹,也未必是最新撤销状态:恶意分发端可能重放旧证书、隐瞒新撤销。客户端检查收到的证书中已有的过期/撤销信息,但不能保证及时获知被隐瞒的撤销。复用本地证书也不会自动获知之后的撤销。重新安装仍优先使用有效的本地证书;若需更新同指纹证书,显式提供从可信渠道取得的新公共证书,再核对其撤销/子钥状态。
|
||||||
|
|
||||||
|
完整指纹和 Email 可在 `clientDefaults` 设置,也可显式安装覆盖。例如先独立核验已下载的本地脚本,再执行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh ./conf-sshd.sh --install -c --gpg-verification on --gpg-fingerprint FULL40HEX --gpg-pin-mode primary --list-id my-servers --gpg-email [email protected]
|
||||||
|
```
|
||||||
|
|
||||||
|
占位指纹必须替换为真实完整指纹;Email 可省略。安装会在本地终端要求输入展示的完整指纹确认,没有静默确认参数。关闭已有验证使用显式重新安装加 `--gpg-verification off`,同样需要终端确认。重新安装可用 `--fallback-urls-file` 指定按行排列的备用文档 URL;`--keyserver` 可重复覆盖内置列表,原有设置默认保留。
|
||||||
|
|
||||||
|
### 签名文档、防回滚与故障
|
||||||
|
|
||||||
|
列表 ID 使用 1–64 个字母、数字、点、下划线或连字符,单独的 `-` 保留给本地未配置状态,不能作为列表名。
|
||||||
|
|
||||||
|
签名文档使用固定 LF 格式:协议标记、列表 ID、单调递增序号、签发/到期 Unix 秒、明确撤销标志、数量、结束标记,以及无注释的规范公钥正文。签名覆盖精确全部字节。
|
||||||
|
|
||||||
|
ON 客户端检查固定列表 ID、有效期和持久化的最高序号/摘要。低序号拒绝;同序号只接受同一文档;明确撤销也必须签名并增加序号。首次安装的最低序号应通过可信渠道确定,否则仅有指纹不能排除有效期内的旧发布。
|
||||||
|
|
||||||
|
更新使用锁、待提交记录、快照检查和原子替换来处理进程中断。待提交发布已过期时不会强行应用,保留其序号下限后允许有效的新发布继续;损坏且无法可信恢复的状态会停止并提示人工检查。不要删除或恢复旧版本的本地防回滚状态;整机快照回滚、本地管理员篡改或底层文件系统掉电持久性不由一个 POSIX 脚本保证。
|
||||||
|
|
||||||
|
下载/验签失败或清单过期会保留最后成功的密钥并报错,**不会自动清空密钥造成锁死**。攻击者仍可阻断更新;离线机器无法保证及时撤权。删除公钥不会终止已有会话,也不会移除管理区块外的手工重复钥匙。部署签名私钥或服务器本地管理员权限失陷超出这层保护范围。
|
||||||
|
|
||||||
|
### 服务器本地验签审计
|
||||||
|
|
||||||
|
仅在本地验签为 ON 时,成功更新会把 `/ssh.keys` 原始响应与配套 `/ssh.keys.sig` 原始签名保存到 `$HOME/.conf-sshd/audit/`。这是实际签名覆盖的完整文档,不是去掉元数据后写入 authorized_keys 的管理区块。
|
||||||
|
|
||||||
|
- `current` 是严格格式的当前记录指针,指向 `a` 或 `b` 槽;槽内有 `ssh.keys`、`ssh.keys.sig` 和 `metadata`。两槽轮替,保留当前与上一份证据,不会随每次 cron 无限增长。
|
||||||
|
- metadata 记录成功安装时间、列表 ID、序号、来源、实际签名主/子钥完整指纹、本地固定的指纹和模式、文档及签名摘要、客户端格式版本。它是本地辅助信息,不是额外签名的远端证明。
|
||||||
|
- 下载或验签失败不覆盖当前成功证据。钥匙、回滚状态与审计指针无法跨文件一次性 rename;提交中断会保留恢复 journal,并且可能暂时没有 current 指针。没有 current 时,不应把任一槽猜作当前已安装证据;先检查错误并用同一固定客户端完成恢复。
|
||||||
|
- OFF 不额外下载或保存签名;成功执行 OFF 更新会取消旧记录的 current 标记,旧证据仅作历史。仅改远端默认值不会关闭本地 ON。
|
||||||
|
- 审计目录权限为 0700,文件为 0600;可结合本地固定证书离线复核签名。它不能防止服务器 root 篡改、删除证据,也不能证明包安装副作用、SSH 当前会话或整机快照未被回滚。
|
||||||
|
|
||||||
|
## 有序备用源
|
||||||
|
|
||||||
|
有两份有意区分的配置:
|
||||||
|
|
||||||
|
1. Worker 部署的 `fallbackSources`:主来源不可用或普通主清单为空时,按顺序取首个有效集合,不合并多个来源。明确撤销阻止回退。
|
||||||
|
2. 每台服务器本地保存的备用 URL:主 Worker 更新失败时,按本地顺序尝试,最多一个主源加五个备用源。每个端点单次请求最多 30 秒;由后续源或下次计划同步重试。只通过显式安装配置改变,远端响应不能覆盖。
|
||||||
|
|
||||||
|
服务器本地备用 URL 必须返回本项目 v1 或 signed-v1 文档;普通原始公钥文件可通过 Worker 来源适配器发布。客户端 ON 时,备用 URL 和对应 `.sig` 必须满足相同指纹、列表 ID、签名、时效和防回滚策略。不能用另一来源的签名拼接,更不能在失败后接受未签名公钥。Worker 不能替未签名备用源伪造可信签名。
|
||||||
|
|
||||||
|
Worker 上游请求有时间、流式字节数、数量限制,并拒绝重定向。没有 Worker 陈旧公钥缓存,每个相关请求重新读取;API 响应 `Cache-Control: no-store`。Gist 的正文与签名取自同一修订。客户端两次 HTTP 请求之间若恰逢新发布,签名不匹配会安全拒绝,后续同步再重试。
|
||||||
|
|
||||||
|
## 配置与部署
|
||||||
|
|
||||||
|
`deployment.json`:
|
||||||
|
|
||||||
|
- `baseUrl`:正式 HTTPS origin,无路径、凭据或末尾斜杠。
|
||||||
|
- `repositoryUrl`:项目 GitHub 地址。
|
||||||
|
- `defaultCron`:新安装默认计划。
|
||||||
|
- `allowEmpty`:普通 Git 模式明确允许撤销全部管理密钥;默认 false。
|
||||||
|
- `fallbackSources`:最多五个公开 HTTPS URL,不含凭据、query、fragment 或本地/IP 地址。
|
||||||
|
- `keySource`:Git 或固定账号 Gist 配置。
|
||||||
|
- `signedRelease`:签名列表身份和可公开发布的证书配置;不是私钥。
|
||||||
|
- `clientDefaults`:首次安装的验签默认值、完整指纹、primary/subkey 范围、列表 ID 和可选公开 Email;不覆盖现有本地设置。
|
||||||
|
|
||||||
|
构建需要 Node.js 22+、npm、Git、OpenSSH 客户端和 POSIX sh;GPG 验证/测试需要 `gpg`。服务器需要 curl、ssh-keygen、awk、mktemp、stat、常用 POSIX 工具及 sha256sum/shasum/openssl 之一,ON 还需要 gpg;计划任务需要 crontab。普通部署可由 root 按需安装 openssh-server,以及启用计划时缺少的 cron/crontabs;支持的包管理器为 apt-get、dnf、yum。已有 sshd 配置无效不当作缺少软件重装。非 root 不安装系统包,缺少 ssh-keygen 时,正常 root 部署可在完成信任确认后先安装客户端工具以便验钥;`-o`、`-n` 和非 root 路径不会这样安装,其他工具仍须预先准备。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
node scripts/verify-release.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
正式上线前配置自己的域名、Worker 名称、正式公钥/签名和最小权限 Cloudflare Secrets。将仓库 `ENABLE_WORKER_DEPLOY=true` 才启用 main 发布;默认关闭。PR 测试不部署。
|
||||||
|
|
||||||
|
### 可选 Git 提交签名门禁
|
||||||
|
|
||||||
|
`REQUIRE_GPG_SIGNATURE=true` 时,发布验证任务先验证本次精确 `GITHUB_SHA`,然后才执行依赖安装、测试和构建。授权完整主指纹和公钥从 GitHub 设置中的 `GPG_ALLOWED_PRIMARY_FINGERPRINTS`、`GPG_PUBLIC_KEYS` 读取,而不是让待部署提交自行指定。
|
||||||
|
|
||||||
|
GitHub 的通用 Verified 标记不是本项目授权名单。最终 merge/squash 提交必须由授权 GPG 钥匙签署;仅 PR 内旧提交签名不够。手动发布也不能绕过同一验证。
|
||||||
|
|
||||||
|
**这是附加门禁,不能替代服务器验签。** 同仓库 workflow/验证器可被有权限者修改,需配合主分支与工作流强制审阅、受保护 production 环境和最小权限凭据。能修改验证工作流并访问发布凭据的人仍在信任边界内。
|
||||||
|
|
||||||
|
### 可选部署邮件通知 hook
|
||||||
|
|
||||||
|
默认关闭。通过 `ENABLE_MAIL_NOTIFICATION`、`MAIL_NOTIFICATION_WEBHOOK` 和可选 `MAIL_NOTIFICATION_TO` 接入你控制的邮件提供方 webhook;本项目不内置 SMTP 服务,也不会自行创建凭据。
|
||||||
|
|
||||||
|
通知说明部署结果、代码 SHA、来源模式、列表身份和**部署配置中的**备用 URL。它不是服务器清单,不知道各机器后来修改的本地备用源,也不是每次 Gist 内容变更的监视器。通知失败或被压制不能当作部署未发生/系统安全的证明。
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| 路径 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| `/` | 浏览器首页;curl/libcurl/wget/HTTPie 返回脚本,显式 HTML 优先 |
|
||||||
|
| `/script.sh` | 安装脚本 |
|
||||||
|
| `/ssh.keys` | 精确公钥文档;签名存在时就是被签名的原始字节 |
|
||||||
|
| `/ssh.keys.sig` | 对上述文档的 detached GPG 签名;没有可用签名时 404 |
|
||||||
|
| `/public-key.gpg` | 配置的公共 GPG 证书;未配置时 404 |
|
||||||
|
| `/release.json` | 来源、数量、摘要、代码修订及签名是否提供;不表示服务器已验签 |
|
||||||
|
|
||||||
|
脚本、Git 公钥/签名与网站一起构建发布;不需要额外 Pages。动态 Gist/备用数据的序号和摘要与代码修订分别表示。静态 CSS/JS 使用内容哈希缓存,未知接口返回 404,不返回 HTML 冒充密钥。
|
||||||
|
|
||||||
|
## 从旧版迁移
|
||||||
|
|
||||||
|
1. 保留恢复会话和备用登录方式,先核对独立服务器清单。
|
||||||
|
2. 显式重新安装新版脚本,检查本地配置、自定义计划、管理区块和手工钥匙。
|
||||||
|
3. 逐台核对脚本版本、完整指纹、验证模式、列表 ID 和序号;等待一段时间不是所有服务器升级完成的证据。
|
||||||
|
4. 再切换新的签名文档格式并按确认流程启用 ON。新版 OFF 可以解析新文档但不验签;旧 v1 客户端会拒绝新格式并保留旧钥匙,不能把它视为已得到保护。
|
||||||
|
5. 另开 SSH 会话验证登录。回滚旧 Worker 可能重新发布历史钥匙;ON 客户端有序号防回滚,但不要依赖普通模式抵抗历史重放。
|
||||||
|
|
||||||
|
## 客户端操作边界
|
||||||
|
|
||||||
|
仅操作当前用户 `$HOME/.ssh/authorized_keys` 的唯一配对管理区块。临时文件、锁、备份、同目录替换和写前快照检查避免可观察到的并发覆盖;不同编辑器不共享锁时仍无操作系统级原子比较交换保证。
|
||||||
|
|
||||||
|
`-p yes/no` 是显式 root 操作,可与普通部署、`--install`、`-c` 组合,不能与只更新钥匙的 `-o` 组合。只修改 PermitRootLogin,先验证公钥,再备份和验证 `sshd -t/-T`,最后替换配置;不改 PasswordAuthentication,不主动 reload/restart。默认部署不会改变登录策略。
|
||||||
|
|
||||||
|
支持没有 Include/Match 的简单配置,以及仅包含标准 `Include /etc/ssh/sshd_config.d/*.conf`(或相对路径 `sshd_config.d/*.conf`)的配置。标准目录及文件必须安全、可读取,目录清单和文件内容在提交前复查。主文件或包含文件出现 Match、嵌套 Include,或其他 Include 写法时明确拒绝自动改策略,交由管理员检查;不会声称几个模拟连接就覆盖所有条件规则。
|
||||||
|
|
||||||
|
普通部署会区分缺少 sshd 程序与现有配置无效。root 可按需安装 openssh-server,`-c` 时可按需安装 cron/crontabs;`-n` 跳过服务端检查/安装,`-o` 不执行这类系统操作。脚本不会主动执行服务 reload/restart,但发行版软件包的安装钩子可能启动服务或生成主机密钥。包安装不是可整体回滚的事务;后续失败会报告,已有钥匙和策略在验证前不改写。
|
||||||
|
|
||||||
|
目标为 Linux dash、Bash POSIX 与 BusyBox ash。测试不等于真实服务器登录、SELinux、发行版服务管理或自定义 AuthorizedKeysFile 已验证。异常强制终止可能留下锁,人工确认原进程状态后再处理。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm test
|
||||||
|
npm run build:test
|
||||||
|
npx wrangler deploy --dry-run --config cf-worker/wrangler.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
测试只使用临时 HOME、命令替身、公开测试 SSH 钥匙及临时无身份/无访问权限的 GPG 测试钥匙。真实浏览器测试仅访问 localhost。`build:test` 产物标记为测试,生产验证拒绝它;上线必须重新生产构建。
|
||||||
|
|
||||||
|
### 真实 WKD / HKPS 集成测试
|
||||||
|
|
||||||
|
CI 的 `Live public GPG discovery (external network)` 是独立任务,与 `npm test` 中不访问外部密钥服务的核心测试分开。它使用真实 GnuPG/dirmngr,在全新的临时钥匙环中通过 WKD、keys.openpgp.org 和 keyserver.ubuntu.com 发现公共证书,再用实际客户端的证书检查函数验证完整主指纹和指定签名子钥;不执行安装器、不改系统信任、不安装 SSH 服务。
|
||||||
|
|
||||||
|
`tests/fixtures/live-discovery.json` 是**仅用于测试的固定公开身份**。Fork 或部署本项目时不应把它改成自己的邮箱/指纹;生产配置只修改 `deployment.json` 与 `keys/`。测试身份不作为服务器 SSH 授权密钥,也不会写入生成的安装默认值。只有维护集成测试本身(例如测试证书到期)时才需要更换此夹具。
|
||||||
|
|
||||||
|
每个来源有独立的结果、有限超时与重试;网络/服务不可用和证书验证失败会明确报告并使该任务失败,不会静默跳过后标为通过。CI 摘要只保留来源、固定指纹与结果,不上传证书或其他 UID 信息。没有该身份签署的 SSH 公钥文档,因此此测试不声称完成了用户真实业务文档验签;签名验签和错误场景仍由隔离的临时测试钥匙覆盖。
|
||||||
|
|
||||||
|
可显式运行同一外网测试:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node scripts/test-live-discovery.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
运行需要允许 dirmngr 创建本地 socket 并访问这些 HTTPS/HKPS 服务。受限环境中仅 HTTP 下载成功不代表 GPG 发现流程通过。
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Apache-2.0,见 [LICENSE](LICENSE)。
|
||||||
|
|||||||
+3
-104
@@ -1,105 +1,4 @@
|
|||||||
// 改成你自己的 Github 用户名,注意是登录 Github 的那个用户名.
|
import release from '../generated/release.js';
|
||||||
const githubUserName = "LamGC";
|
import { createHandler } from './router.js';
|
||||||
// 改成你 Fork 后的仓库名,记得要开启 Github Pages 功能.
|
|
||||||
const githubInstSshProjectName = "quickly-conf-sshd";
|
|
||||||
// 如果可以,建议在此设置备用的 SSH 公钥, 以防 Github 无法使用.
|
|
||||||
const backupSshKeys = ``;
|
|
||||||
// Worker 的访问地址, 如果不填的话默认为请求的地址, 填了就会用这里的地址(要去 Worker 的触发器那绑定, 否则无效).
|
|
||||||
const defaultBaseUrl = "";
|
|
||||||
// Cron 表达式, 默认 1 天执行一次更新.
|
|
||||||
const cronExpression = "0 0 * * *";
|
|
||||||
|
|
||||||
// 下面的东西一般不用改.
|
export default { fetch: createHandler(release) };
|
||||||
const baseRepoPageUrl = `https://${githubUserName.toLowerCase()}.github.io/${githubInstSshProjectName}/`;
|
|
||||||
const installScriptUrl = `${baseRepoPageUrl}/conf-sshd.sh`;
|
|
||||||
// 如果出现 Github 无法使用的情况, 可以修改 sshKeyUrl 来变更位置.
|
|
||||||
// 也可以添加额外的 SSH 公钥地址(比如 KeyBase).
|
|
||||||
const sshKeyUrls = [
|
|
||||||
`https://github.com/${githubUserName}.keys`
|
|
||||||
];
|
|
||||||
|
|
||||||
// 下面是脚本的占位符.
|
|
||||||
const SCRIPT_PH_SSH_KEY_URL = "{{ SSH_KEY_URL }}"
|
|
||||||
const SCRIPT_PH_SCRIPT_URL = "{{ SCRIPT_URL }}"
|
|
||||||
const SCRIPT_PH_DEFAULT_CRON = "{{ DEFAULT_CRON }}"
|
|
||||||
|
|
||||||
|
|
||||||
async function sendScriptContent(baseUrl) {
|
|
||||||
let scriptResp = await fetch(new Request(installScriptUrl));
|
|
||||||
if (scriptResp.ok) {
|
|
||||||
let scriptContent = await scriptResp.text();
|
|
||||||
|
|
||||||
scriptContent = scriptContent.replace(SCRIPT_PH_SSH_KEY_URL, `${baseUrl}/ssh.keys`)
|
|
||||||
.replace(SCRIPT_PH_SCRIPT_URL, `${baseUrl}/script.sh`)
|
|
||||||
.replace(SCRIPT_PH_DEFAULT_CRON, cronExpression)
|
|
||||||
|
|
||||||
return new Response(scriptContent, {
|
|
||||||
headers: {
|
|
||||||
"content-type": "text/plain; charset=utf-8"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return new Response("Failed to get install script.", {
|
|
||||||
status: 500,
|
|
||||||
statusText: "Failed to get install script",
|
|
||||||
headers: {
|
|
||||||
"content-type": "text/plain; charset=utf-8"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
async fetch(request, env) {
|
|
||||||
const { pathname, host } = new URL(request.url);
|
|
||||||
const baseUrl = defaultBaseUrl || `https://${host}`;
|
|
||||||
if (pathname === "/ssh.keys") {
|
|
||||||
for (let url of sshKeyUrls) {
|
|
||||||
let response = await fetch(new Request(url));
|
|
||||||
if (response.ok) {
|
|
||||||
let keys = await response.text()
|
|
||||||
return new Response(keys, {
|
|
||||||
headers: {
|
|
||||||
"content-type": "text/plain; charset=utf-8"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (backupSshKeys.length > 0) {
|
|
||||||
return new Response(backupSshKeys, {
|
|
||||||
headers: {
|
|
||||||
"content-type": "text/plain; charset=utf-8"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return new Response("Failed to get keys.", {
|
|
||||||
status: 500,
|
|
||||||
statusText: "Failed to get keys",
|
|
||||||
headers: {
|
|
||||||
"content-type": "text/plain; charset=utf-8"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (pathname === "/") {
|
|
||||||
const userAgent = request.headers.get("User-Agent");
|
|
||||||
if (userAgent != null && userAgent.match(/curl|libcurl/) !== null) {
|
|
||||||
return await sendScriptContent(baseUrl);
|
|
||||||
} else {
|
|
||||||
return new Response("", {
|
|
||||||
status: 301,
|
|
||||||
statusText: "Redirect",
|
|
||||||
headers: {
|
|
||||||
"Location": baseRepoPageUrl
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (pathname === "/script.sh") {
|
|
||||||
return await sendScriptContent(baseUrl);
|
|
||||||
} else {
|
|
||||||
return new Response("Not found.", {
|
|
||||||
status: 404,
|
|
||||||
statusText: "Not Found"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import {parsePublishedSignedRelease, withoutKeyComments, SIGNED_BUNDLE_MAX_BYTES, SIGNATURE_SEPARATOR, SIGNED_RELEASE_LIMITS, validateListId} from './signed-release.js';
|
||||||
|
|
||||||
|
// Runtime fallback is deliberately stateless: a request never reuses earlier keys.
|
||||||
|
export const KEY_SOURCE_LIMITS = Object.freeze({sources: 5, timeoutMs: 3000, maxBytes: 1024 * 1024, maxKeys: 1000});
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const decoder = new TextDecoder('utf-8', {fatal: true});
|
||||||
|
const keyTypes = new Set(['ssh-ed25519', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', '[email protected]', '[email protected]']);
|
||||||
|
const curves = {nistp256: {name: 'P-256', size: 32}, nistp384: {name: 'P-384', size: 48}, nistp521: {name: 'P-521', size: 66}};
|
||||||
|
|
||||||
|
class SourceError extends Error {
|
||||||
|
constructor(code, status) { super(code); this.code = code; if (status !== undefined) this.status = status; }
|
||||||
|
}
|
||||||
|
export class FallbackUnavailableError extends Error {
|
||||||
|
constructor(diagnostics = []) {
|
||||||
|
super('No configured SSH key source returned a valid nonempty key set.');
|
||||||
|
this.name = 'FallbackUnavailableError';
|
||||||
|
this.code = 'KEY_SOURCES_UNAVAILABLE';
|
||||||
|
// Keep diagnostics safe to log or return: no response content, URL secrets, or exception text.
|
||||||
|
this.diagnostics = diagnostics.slice(0, KEY_SOURCE_LIMITS.sources).map(({index, code, status}) => ({index, code, ...(status === undefined ? {} : {status})}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateFallbackSources(sources = []) {
|
||||||
|
if (!Array.isArray(sources) || sources.length > KEY_SOURCE_LIMITS.sources) throw new Error('fallbackSources must contain at most five HTTPS URLs.');
|
||||||
|
const seen = new Set();
|
||||||
|
return sources.map(value => {
|
||||||
|
if (typeof value !== 'string' || value.length > 2048 || /[\s\u0000-\u001f\u007f-\u009f]/u.test(value)) throw new Error('Each fallback source must be a public-host HTTPS URL without credentials, queries, or fragments.');
|
||||||
|
let url;
|
||||||
|
try { url = new URL(value); } catch { throw new Error('Invalid fallback source URL.'); }
|
||||||
|
if (url.protocol !== 'https:' || !url.hostname || url.username || url.password || url.search || value.includes('?') || url.hash || value.includes('#') || !value.startsWith('https://')) throw new Error('Each fallback source must be a public-host HTTPS URL without credentials, queries, or fragments.');
|
||||||
|
// Configuration is trusted, but exclude local names and IP literals before any network call.
|
||||||
|
// A hostname allowlist is not a DNS-resolution guarantee; source operators remain trusted.
|
||||||
|
if (url.hostname.length > 253 || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(url.hostname) || /\.(?:localhost|local|localdomain|internal|intranet|lan|home|corp|onion)$/i.test(url.hostname)) throw new Error('Fallback sources must use public HTTPS hostnames, not local names or IP addresses.');
|
||||||
|
if (seen.has(url.href)) throw new Error('Duplicate fallback source URL.');
|
||||||
|
seen.add(url.href);
|
||||||
|
return url.href;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeBase64(bytes) {
|
||||||
|
let binary = '';
|
||||||
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
function decodeBase64(encoded) {
|
||||||
|
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length % 4 === 1) throw new Error('Invalid public key encoding.');
|
||||||
|
let decoded;
|
||||||
|
try { decoded = Uint8Array.from(atob(encoded), c => c.charCodeAt(0)); } catch { throw new Error('Invalid public key encoding.'); }
|
||||||
|
if (encodeBase64(decoded).replace(/=+$/, '') !== encoded.replace(/=+$/, '')) throw new Error('Invalid public key encoding.');
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
async function sha256(bytes) { return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); }
|
||||||
|
function hex(bytes) { return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''); }
|
||||||
|
|
||||||
|
class WireReader {
|
||||||
|
constructor(bytes) { this.bytes = bytes; this.offset = 0; }
|
||||||
|
string() {
|
||||||
|
if (this.offset + 4 > this.bytes.length) throw new Error('Truncated public key data.');
|
||||||
|
const length = new DataView(this.bytes.buffer, this.bytes.byteOffset + this.offset, 4).getUint32(0);
|
||||||
|
this.offset += 4;
|
||||||
|
if (length > this.bytes.length - this.offset) throw new Error('Truncated public key field.');
|
||||||
|
const value = this.bytes.subarray(this.offset, this.offset + length);
|
||||||
|
this.offset += length;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
text() { return decoder.decode(this.string()); }
|
||||||
|
end() { if (this.offset !== this.bytes.length) throw new Error('Unexpected trailing public key data.'); }
|
||||||
|
}
|
||||||
|
function positiveMpint(bytes) {
|
||||||
|
if (!bytes.length || (bytes[0] & 128) || (bytes[0] === 0 && (bytes.length === 1 || !(bytes[1] & 128)))) throw new Error('RSA fields must be positive, minimally encoded mpints.');
|
||||||
|
return bytes[0] === 0 ? bytes.subarray(1) : bytes;
|
||||||
|
}
|
||||||
|
async function validatePoint(reader, curveName) {
|
||||||
|
if (reader.text() !== curveName) throw new Error('ECDSA curve does not match key type.');
|
||||||
|
const point = reader.string(), curve = curves[curveName];
|
||||||
|
if (point.length !== 1 + 2 * curve.size || point[0] !== 4) throw new Error('Invalid uncompressed ECDSA point.');
|
||||||
|
// Web Crypto also rejects off-curve points, rather than just checking wire lengths.
|
||||||
|
try { await crypto.subtle.importKey('raw', point, {name: 'ECDSA', namedCurve: curve.name}, false, ['verify']); }
|
||||||
|
catch { throw new Error('Invalid ECDSA point.'); }
|
||||||
|
}
|
||||||
|
function validateApplication(reader) {
|
||||||
|
const value = reader.string();
|
||||||
|
if (!value.length || value.length > 1024 || /[\u0000-\u001f\u007f-\u009f]/u.test(decoder.decode(value))) throw new Error('Invalid security-key application.');
|
||||||
|
}
|
||||||
|
async function validateWire(type, bytes) {
|
||||||
|
const reader = new WireReader(bytes);
|
||||||
|
if (reader.text() !== type) throw new Error('Public key type does not match key data.');
|
||||||
|
if (type === 'ssh-ed25519' || type === '[email protected]') {
|
||||||
|
if (reader.string().length !== 32) throw new Error('Ed25519 public keys must contain 32 bytes.');
|
||||||
|
if (type.startsWith('sk-')) validateApplication(reader);
|
||||||
|
} else if (type === 'ssh-rsa') {
|
||||||
|
const e = positiveMpint(reader.string()), n = positiveMpint(reader.string());
|
||||||
|
const bits = (n.length - 1) * 8 + 32 - Math.clz32(n[0]);
|
||||||
|
if (bits < 2048 || bits > 16384) throw new Error('RSA keys must have 2048 to 16384 bits.');
|
||||||
|
if (e.length > 8 || (e.length === 1 && e[0] < 3) || !(e.at(-1) & 1) || !(n.at(-1) & 1)) throw new Error('Invalid RSA exponent or modulus.');
|
||||||
|
} else if (type.startsWith('ecdsa-sha2-')) {
|
||||||
|
await validatePoint(reader, type.slice('ecdsa-sha2-'.length));
|
||||||
|
} else if (type === '[email protected]') {
|
||||||
|
await validatePoint(reader, 'nistp256');
|
||||||
|
validateApplication(reader);
|
||||||
|
} else throw new Error('Unsupported public key type.');
|
||||||
|
reader.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse plain OpenSSH keys, never authorized_keys options, certificates, or executable content. */
|
||||||
|
export async function parsePublicKeys(input, {maxKeys = KEY_SOURCE_LIMITS.maxKeys, signal} = {}) {
|
||||||
|
if (signal?.aborted) throw new SourceError('timeout');
|
||||||
|
if (!Number.isInteger(maxKeys) || maxKeys < 1 || maxKeys > KEY_SOURCE_LIMITS.maxKeys) throw new Error('Invalid public key count limit.');
|
||||||
|
if (typeof input !== 'string' || encoder.encode(input).length > KEY_SOURCE_LIMITS.maxBytes) throw new Error('Public key input exceeds its size limit.');
|
||||||
|
const normalized = input.replace(/\r\n/g, '\n');
|
||||||
|
if (/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\ufffd]/u.test(normalized) || /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(normalized)) throw new Error('Public key input contains invalid control characters or Unicode.');
|
||||||
|
if (/-----BEGIN [^-\r\n]*PRIVATE KEY-----|PuTTY-User-Key-File-[0-9]+:/i.test(normalized)) throw new Error('Private keys are never accepted.');
|
||||||
|
const keys = [], seen = new Set();
|
||||||
|
for (const raw of normalized.split('\n')) {
|
||||||
|
if (signal?.aborted) throw new SourceError('timeout');
|
||||||
|
const line = raw.replace(/^[ \t]+|[ \t]+$/g, '');
|
||||||
|
if (!line || line.startsWith('#')) continue;
|
||||||
|
if (encoder.encode(line).length > 8192) throw new Error('Public key line exceeds its size limit.');
|
||||||
|
const match = /^(\S+)[ \t]+([A-Za-z0-9+/]+={0,2})(?:[ \t]+(.*))?$/u.exec(line);
|
||||||
|
if (!match || !keyTypes.has(match[1])) throw new Error('Expected a supported plain OpenSSH public key without options or certificates.');
|
||||||
|
if (keys.length >= maxKeys) throw new Error('Too many public keys.');
|
||||||
|
const [, type, encoded, rawComment = ''] = match;
|
||||||
|
const comment = rawComment.replace(/[ \t]+/g, ' ').trim();
|
||||||
|
const bytes = decodeBase64(encoded), identity = encodeBase64(bytes);
|
||||||
|
if (seen.has(identity)) throw new Error('Duplicate public key.');
|
||||||
|
await validateWire(type, bytes);
|
||||||
|
if (signal?.aborted) throw new SourceError('timeout');
|
||||||
|
seen.add(identity);
|
||||||
|
keys.push({text: `${type} ${identity}${comment ? ` ${comment}` : ''}`, type, label: comment || `Server key ${keys.length + 1}`, fingerprint: `SHA256:${encodeBase64(await sha256(bytes)).replace(/=+$/, '')}`});
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function makeKeyBundle(keys, revision, allowEmpty) {
|
||||||
|
if (!/^[0-9a-f]{40}$/.test(revision)) throw new Error('Release revision must be a 40-character Git commit SHA.');
|
||||||
|
if (typeof allowEmpty !== 'boolean') throw new Error('allowEmpty must be an explicit boolean.');
|
||||||
|
if (!keys.length && !allowEmpty) throw new Error('An empty key set requires explicit revocation.');
|
||||||
|
const payload = withoutKeyComments(keys).map(key => `${key.text}\n`).join('');
|
||||||
|
const digest = hex(await sha256(encoder.encode(payload)));
|
||||||
|
const text = `# conf-sshd-v1\n# revision ${revision}\n# key-count ${keys.length}\n# allow-empty ${allowEmpty ? 'yes' : 'no'}\n# sha256 ${digest}\n# end-metadata\n${payload}`;
|
||||||
|
if (encoder.encode(text).length > KEY_SOURCE_LIMITS.maxBytes) throw new SourceError('too-large');
|
||||||
|
return {sha256: digest, text};
|
||||||
|
}
|
||||||
|
async function snapshot(keys, release, source, signed = null) {
|
||||||
|
if (!/^[0-9a-f]{40}$/.test(release.revision)) throw Error('Release revision must be a 40-character Git commit SHA.');
|
||||||
|
keys = withoutKeyComments(keys);
|
||||||
|
const allowEmpty = signed ? signed.metadata.revoke : release.allowEmpty;
|
||||||
|
const bundle = signed ? {text: signed.document, sha256: hex(await sha256(encoder.encode(keys.map(key => key.text + '\n').join(''))))} : await makeKeyBundle(keys, release.revision, allowEmpty);
|
||||||
|
return {keys, keysText: signed?.document ?? bundle.text, sha256: bundle.sha256, keyCount: keys.length, allowEmpty, source, revision: release.revision, keyRevision: signed ? signed.metadata.documentSha256 : bundle.sha256, fetchedAt: new Date().toISOString(), signedDocument: signed?.document ?? null, signature: signed?.signature ?? null, signatureAvailable: Boolean(signed?.signature), signedBundle: signed?.bundle ?? null, signedMetadata: signed?.metadata ?? null};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateKeySource(value = {type: 'git'}) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw Error('keySource must be an object.');
|
||||||
|
const allowed = value.type === 'git' ? ['type'] : ['type', 'id', 'ownerId', 'ownerLogin', 'file', 'signatureFile', 'bundleFile'];
|
||||||
|
if (!['git', 'gist'].includes(value.type) || Object.keys(value).some(key => !allowed.includes(key))) throw Error('Invalid keySource configuration.');
|
||||||
|
if (value.type === 'git') return {type: 'git'};
|
||||||
|
if (typeof value.id !== 'string' || !/^[0-9a-f]{5,64}$/.test(value.id)) throw Error('Gist source requires a fixed lowercase hexadecimal ID.');
|
||||||
|
if (!Number.isSafeInteger(value.ownerId) || value.ownerId < 1) throw Error('Gist source requires a positive numeric ownerId.');
|
||||||
|
if (typeof value.ownerLogin !== 'string' || !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(value.ownerLogin)) throw Error('Gist source requires an ownerLogin display name.');
|
||||||
|
const file = value.file ?? 'ssh.keys', signatureFile = value.signatureFile ?? `${file}.sig`, bundleFile = value.bundleFile ?? 'release.bundle';
|
||||||
|
for (const name of [file, signatureFile, bundleFile]) if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(name)) throw Error('Gist filenames must be fixed plain filenames.');
|
||||||
|
if (new Set([file, signatureFile, bundleFile]).size !== 3) throw Error('Gist key, signature, and bundle filenames must differ.');
|
||||||
|
return {type: 'gist', id: value.id, ownerId: value.ownerId, ownerLogin: value.ownerLogin, file, signatureFile, bundleFile};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSignedReleaseConfig(value) {
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !['listId', 'publicKeyFile', 'fingerprint'].includes(key))) throw Error('Invalid signedRelease configuration.');
|
||||||
|
validateListId(value.listId);
|
||||||
|
if (Boolean(value.publicKeyFile) !== Boolean(value.fingerprint)) throw Error('Build signature verification requires both publicKeyFile and fingerprint.');
|
||||||
|
if (value.publicKeyFile !== undefined && (typeof value.publicKeyFile !== 'string' || !/^keys\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value.publicKeyFile))) throw Error('Signing public key must be a fixed file inside keys/.');
|
||||||
|
if (value.fingerprint !== undefined && (typeof value.fingerprint !== 'string' || !/^[A-Fa-f0-9]{40}$/.test(value.fingerprint))) throw Error('Signing fingerprint must be the complete 40-character primary fingerprint.');
|
||||||
|
return {listId: value.listId, ...(value.publicKeyFile ? {publicKeyFile: value.publicKeyFile, fingerprint: value.fingerprint.toUpperCase()} : {})};
|
||||||
|
}
|
||||||
|
function boundedOption(options, name) {
|
||||||
|
const value = options[name] ?? KEY_SOURCE_LIMITS[name];
|
||||||
|
if (!Number.isInteger(value) || value < 1 || value > KEY_SOURCE_LIMITS[name]) throw new Error(`Invalid ${name} limit.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
async function readBody(response, signal, maxBytes) {
|
||||||
|
const declaredLength = response.headers.get('Content-Length');
|
||||||
|
if (declaredLength && /^\d+$/.test(declaredLength) && Number(declaredLength) > maxBytes) throw new SourceError('too-large');
|
||||||
|
if (!response.body) return '';
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
// Grow one bounded buffer, so an upstream sending tiny chunks cannot grow an unbounded chunk list.
|
||||||
|
let bytes = new Uint8Array(Math.min(16384, maxBytes));
|
||||||
|
let total = 0, complete = false;
|
||||||
|
const cancel = () => { void reader.cancel().catch(() => {}); };
|
||||||
|
signal.addEventListener('abort', cancel, {once: true});
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
if (signal.aborted) throw new SourceError('timeout');
|
||||||
|
const {value, done} = await reader.read();
|
||||||
|
if (signal.aborted) throw new SourceError('timeout');
|
||||||
|
if (done) { complete = true; break; }
|
||||||
|
const nextTotal = total + value.byteLength;
|
||||||
|
if (nextTotal > maxBytes) throw new SourceError('too-large');
|
||||||
|
if (nextTotal > bytes.length) {
|
||||||
|
const expanded = new Uint8Array(Math.min(maxBytes, Math.max(nextTotal, bytes.length * 2)));
|
||||||
|
expanded.set(bytes);
|
||||||
|
bytes = expanded;
|
||||||
|
}
|
||||||
|
bytes.set(value, total);
|
||||||
|
total = nextTotal;
|
||||||
|
}
|
||||||
|
try { return decoder.decode(bytes.subarray(0, total)); } catch { throw new SourceError('invalid-utf8'); }
|
||||||
|
} finally {
|
||||||
|
signal.removeEventListener('abort', cancel);
|
||||||
|
if (!complete) cancel();
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function withDeadline(limits, operation, onTimeout) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let timer;
|
||||||
|
const timeout = new Promise((resolve, reject) => {
|
||||||
|
timer = setTimeout(() => { const recovery = onTimeout?.(); recovery === undefined ? reject(new SourceError('timeout')) : resolve(recovery); controller.abort(); }, limits.timeoutMs);
|
||||||
|
});
|
||||||
|
try { return await Promise.race([operation(controller.signal), timeout]); }
|
||||||
|
finally { clearTimeout(timer); }
|
||||||
|
}
|
||||||
|
async function fetchText(url, fetchImpl, signal, maxBytes, github = false) {
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetchImpl(url, {method: 'GET', redirect: 'manual', cache: 'no-store', headers: {'Accept': github ? 'application/vnd.github+json' : 'text/plain, application/octet-stream;q=0.9', 'Cache-Control': 'no-cache, no-store', 'Pragma': 'no-cache', ...(github ? {'User-Agent': 'conf-sshd-key-source', 'X-GitHub-Api-Version': '2022-11-28'} : {})}, signal});
|
||||||
|
if (signal.aborted) throw new SourceError('timeout');
|
||||||
|
if (response.redirected || (response.status >= 300 && response.status < 400)) throw new SourceError('redirect', response.status);
|
||||||
|
if (response.status !== 200) throw new SourceError('http-error', response.status);
|
||||||
|
return await readBody(response, signal, maxBytes);
|
||||||
|
} finally {
|
||||||
|
if (response?.body && !response.body.locked) void response.body.cancel().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function parseSourceText(body, release, limits, options, signal, signature = null) {
|
||||||
|
if (options.requireSigned || body.startsWith('# conf-sshd-signed-v1\n')) {
|
||||||
|
try {
|
||||||
|
const separator = body.indexOf(SIGNATURE_SEPARATOR);
|
||||||
|
const document = separator < 0 ? body : body.slice(0, separator);
|
||||||
|
if (separator >= 0) signature = body.slice(separator + SIGNATURE_SEPARATOR.length);
|
||||||
|
const signed = await parsePublishedSignedRelease(document, signature, {listId: release.signedRelease?.listId, now: options.now, checkFreshness: Boolean(options.requireSigned), requireSigned: options.requireSigned, signal});
|
||||||
|
if (signed.keys.length > limits.maxKeys) throw Error('Too many keys.');
|
||||||
|
return {keys: signed.keys, signed};
|
||||||
|
} catch { throw new SourceError('invalid-signed-release'); }
|
||||||
|
}
|
||||||
|
let keys;
|
||||||
|
try { keys = await parsePublicKeys(body, {maxKeys: limits.maxKeys, signal}); } catch { throw new SourceError('invalid-keys'); }
|
||||||
|
if (!keys.length) throw new SourceError('empty');
|
||||||
|
return {keys, signed: null};
|
||||||
|
}
|
||||||
|
async function fetchSource(url, fetchImpl, limits, release, options) {
|
||||||
|
let withoutSignature;
|
||||||
|
return withDeadline(limits, async signal => {
|
||||||
|
const body = await fetchText(url, fetchImpl, signal, limits.maxBytes === KEY_SOURCE_LIMITS.maxBytes ? SIGNED_BUNDLE_MAX_BYTES : limits.maxBytes);
|
||||||
|
if (body.startsWith('# conf-sshd-signed-v1\n') && !body.includes(SIGNATURE_SEPARATOR)) {
|
||||||
|
// A detached pair never combines different sources. A publication race remains a client signature failure.
|
||||||
|
withoutSignature = await parseSourceText(body, release, limits, {...options, requireSigned: false}, signal);
|
||||||
|
let signature = null;
|
||||||
|
try { signature = await fetchText(url + '.sig', fetchImpl, signal, SIGNED_RELEASE_LIMITS.signatureBytes); } catch { /* OFF remains usable when the optional signature is unavailable. */ }
|
||||||
|
return parseSourceText(body, release, limits, options, signal.aborted ? undefined : signal, signature);
|
||||||
|
}
|
||||||
|
return parseSourceText(body, release, limits, options, signal);
|
||||||
|
}, () => options.requireSigned ? undefined : withoutSignature);
|
||||||
|
}
|
||||||
|
async function fetchGist(source, fetchImpl, limits, release, options) {
|
||||||
|
return withDeadline(limits, async signal => {
|
||||||
|
// Both requests, streamed bodies and parsing share one deadline; never follow an upstream raw_url.
|
||||||
|
const get = async suffix => {
|
||||||
|
const body = await fetchText(`https://api.github.com/gists/${source.id}${suffix}`, fetchImpl, signal, 2 * SIGNED_BUNDLE_MAX_BYTES, true);
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(body); } catch { throw new SourceError('invalid-gist'); }
|
||||||
|
if (!data || data.id !== source.id || data.owner?.id !== source.ownerId) throw new SourceError('gist-owner-mismatch');
|
||||||
|
if (data.public !== true || data.truncated === true || !data.files || typeof data.files !== 'object' || Array.isArray(data.files)) throw new SourceError('invalid-gist');
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
const latest = await get('');
|
||||||
|
const gistRevision = latest.history?.[0]?.version;
|
||||||
|
if (typeof gistRevision !== 'string' || !/^[0-9a-f]{40}$/.test(gistRevision)) throw new SourceError('invalid-gist-revision');
|
||||||
|
const pinned = await get(`/${gistRevision}`);
|
||||||
|
const filename = Object.hasOwn(pinned.files, source.bundleFile) ? source.bundleFile : source.file;
|
||||||
|
const file = pinned.files[filename];
|
||||||
|
if (!file || file.filename !== filename || file.truncated !== false || typeof file.content !== 'string' || (file.encoding !== undefined && file.encoding !== 'utf-8')) throw new SourceError('invalid-gist-file');
|
||||||
|
if (filename === source.bundleFile && (!file.content.startsWith('# conf-sshd-signed-v1\n') || !file.content.includes(SIGNATURE_SEPARATOR))) throw new SourceError('invalid-signed-release');
|
||||||
|
const size = encoder.encode(file.content).length;
|
||||||
|
const max = filename === source.bundleFile ? SIGNED_BUNDLE_MAX_BYTES : limits.maxBytes;
|
||||||
|
if (!Number.isSafeInteger(file.size) || file.size !== size || size > max) throw new SourceError('too-large');
|
||||||
|
// Optional signature is read only from this same immutable revision, never from latest/raw URLs.
|
||||||
|
let signature = null;
|
||||||
|
const signatureFile = pinned.files[source.signatureFile];
|
||||||
|
if (signatureFile && signatureFile.filename === source.signatureFile && signatureFile.truncated === false && typeof signatureFile.content === 'string' && (signatureFile.encoding === undefined || signatureFile.encoding === 'utf-8') && Number.isSafeInteger(signatureFile.size) && signatureFile.size === encoder.encode(signatureFile.content).length && signatureFile.size <= SIGNED_RELEASE_LIMITS.signatureBytes) signature = signatureFile.content;
|
||||||
|
const result = await parseSourceText(file.content, release, limits, options, signal, signature);
|
||||||
|
return {...result, source: {type: 'gist', id: source.id, ownerId: source.ownerId, ownerLogin: source.ownerLogin, gistRevision, file: filename, url: `https://gist.github.com/${source.ownerLogin}/${source.id}/${gistRevision}`}};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only release-configured sources are accepted. Worker checks structure, never claims OpenPGP verification. */
|
||||||
|
export async function resolveKeys(release, fetchImpl = globalThis.fetch, internalOptions = {}) {
|
||||||
|
if (!release || !Array.isArray(release.keys) || typeof release.allowEmpty !== 'boolean') throw new Error('Invalid deployed key release.');
|
||||||
|
const keySource = validateKeySource(release.keySource);
|
||||||
|
validateSignedReleaseConfig(release.signedRelease);
|
||||||
|
if (internalOptions.requireSigned !== undefined && typeof internalOptions.requireSigned !== 'boolean') throw Error('requireSigned must be boolean.');
|
||||||
|
const limits = {timeoutMs: boundedOption(internalOptions, 'timeoutMs'), maxBytes: boundedOption(internalOptions, 'maxBytes'), maxKeys: boundedOption(internalOptions, 'maxKeys')};
|
||||||
|
const sources = validateFallbackSources(release.fallbackSources);
|
||||||
|
const diagnostics = [];
|
||||||
|
if (keySource.type === 'git') {
|
||||||
|
if (release.signedDocument || release.signedBundle) {
|
||||||
|
// A malformed signed primary must never downgrade to different unsigned repository keys.
|
||||||
|
try {
|
||||||
|
const result = await parseSourceText(release.signedDocument || release.signedBundle, release, limits, internalOptions, undefined, release.signature ?? null);
|
||||||
|
return await snapshot(result.keys, release, {type: result.signed.metadata.revoke ? 'explicit-empty' : 'repository'}, result.signed);
|
||||||
|
} catch { diagnostics.push({index: -1, code: 'invalid-signed-release'}); }
|
||||||
|
} else if (!internalOptions.requireSigned) {
|
||||||
|
if (release.keys.length) return snapshot(release.keys, release, {type: 'repository'});
|
||||||
|
// An explicit empty release means revoke. It must never be repopulated from a fallback.
|
||||||
|
if (release.allowEmpty) return snapshot([], release, {type: 'explicit-empty'});
|
||||||
|
}
|
||||||
|
// An explicit unsigned empty primary cannot be replaced by signed fallback keys either.
|
||||||
|
if (!release.signedDocument && !release.signedBundle && release.allowEmpty && !release.keys.length) throw new FallbackUnavailableError([{index: -1, code: 'unsigned-revocation'}]);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const result = await fetchGist(keySource, fetchImpl, limits, release, internalOptions);
|
||||||
|
return await snapshot(result.keys, release, result.source, result.signed);
|
||||||
|
} catch (error) { diagnostics.push(diagnostic(error, -1)); }
|
||||||
|
}
|
||||||
|
for (const [index, url] of sources.entries()) {
|
||||||
|
try {
|
||||||
|
const result = await fetchSource(url, fetchImpl, limits, release, internalOptions);
|
||||||
|
return await snapshot(result.keys, release, {type: 'fallback', url}, result.signed);
|
||||||
|
} catch (error) { diagnostics.push(diagnostic(error, index)); }
|
||||||
|
}
|
||||||
|
throw new FallbackUnavailableError(diagnostics);
|
||||||
|
}
|
||||||
|
function diagnostic(error, index) {
|
||||||
|
return {index, code: error instanceof SourceError ? error.code : 'network-error', ...(error instanceof SourceError && error.status !== undefined ? {status: error.status} : {})};
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { resolveKeys } from './key-source.js';
|
||||||
|
import { renderPage } from './view.js';
|
||||||
|
const securityHeaders = {
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
'Referrer-Policy': 'no-referrer',
|
||||||
|
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Repository keys are bundled; configured empty-primary fallback resolves one snapshot per response. */
|
||||||
|
export function createHandler(release, {fetchImpl = globalThis.fetch} = {}) {
|
||||||
|
return async function fetch(request, env) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const headers = new Headers(securityHeaders);
|
||||||
|
headers.set('Cache-Control', 'no-store');
|
||||||
|
headers.set('X-Conf-Sshd-Revision', release.revision);
|
||||||
|
headers.set('X-Conf-Sshd-Test-Fixture', String(Boolean(release.testFixture)));
|
||||||
|
if (!['GET', 'HEAD'].includes(request.method)) {
|
||||||
|
headers.set('Allow', 'GET, HEAD');
|
||||||
|
return new Response('Method not allowed.\n', {status: 405, headers});
|
||||||
|
}
|
||||||
|
const respond = (body, type, status = 200) => {
|
||||||
|
headers.set('Content-Type', type);
|
||||||
|
return new Response(request.method === 'HEAD' ? null : body, {status, headers});
|
||||||
|
};
|
||||||
|
if (url.pathname === '/script.sh') return respond(release.script, 'text/plain; charset=utf-8');
|
||||||
|
if (url.pathname === '/public-key.gpg') {
|
||||||
|
if (!release.publicCertificate) return respond('No public signing certificate is configured.\n', 'text/plain; charset=utf-8', 404);
|
||||||
|
return respond(release.publicCertificate, 'application/pgp-keys');
|
||||||
|
}
|
||||||
|
let snapshot;
|
||||||
|
if (['/ssh.keys','/ssh.keys.sig','/release.json','/'].includes(url.pathname)) {
|
||||||
|
const accept = request.headers.get('Accept') || '';
|
||||||
|
const legacyScript = url.pathname === '/' && !accept.toLowerCase().includes('text/html') && /(?:curl|libcurl|wget|httpie)\//i.test(request.headers.get('User-Agent') || '');
|
||||||
|
if (!legacyScript) {
|
||||||
|
try {
|
||||||
|
snapshot = {...release, ...await resolveKeys(release, fetchImpl)};
|
||||||
|
headers.set('X-Conf-Sshd-Key-Digest', snapshot.sha256);
|
||||||
|
headers.set('X-Conf-Sshd-Key-Source', snapshot.source.type);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'KEY_SOURCES_UNAVAILABLE') throw error;
|
||||||
|
if (url.pathname !== '/') return respond('No valid public key source is currently available. Existing server keys must be preserved.\n', 'text/plain; charset=utf-8', 503);
|
||||||
|
snapshot = {...release,keys:[],keyCount:0,sha256:null,source:{type:'unavailable'}};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (url.pathname === '/ssh.keys') return respond(snapshot.keysText, 'text/plain; charset=utf-8');
|
||||||
|
if (url.pathname === '/ssh.keys.sig') {
|
||||||
|
// This detached signature covers every byte returned by /ssh.keys. Never
|
||||||
|
// manufacture a signature for an unsigned source or transform signed data.
|
||||||
|
if (!snapshot.signature || !snapshot.signedDocument || snapshot.keysText !== snapshot.signedDocument) {
|
||||||
|
return respond('No detached signature is available for this key response.\n', 'text/plain; charset=utf-8', 404);
|
||||||
|
}
|
||||||
|
return respond(snapshot.signature, 'application/pgp-signature');
|
||||||
|
}
|
||||||
|
if (url.pathname === '/release.json') {
|
||||||
|
return respond(JSON.stringify({schema:1,revision:snapshot.revision,publishedAt:snapshot.publishedAt,
|
||||||
|
keyCount:snapshot.keyCount,allowEmpty:snapshot.allowEmpty,sha256:snapshot.sha256,keyRevision:snapshot.keyRevision ?? snapshot.sha256,
|
||||||
|
source:snapshot.source,fetchedAt:snapshot.fetchedAt,signatureAvailable:Boolean(snapshot.signature),certificateAvailable:Boolean(release.publicCertificate),
|
||||||
|
signedMetadata:snapshot.signedMetadata ?? null,signatureVerifiedByWorker:false,testFixture:Boolean(release.testFixture)})+'\n','application/json; charset=utf-8');
|
||||||
|
}
|
||||||
|
if (url.pathname === '/') {
|
||||||
|
headers.set('Vary', 'Accept, User-Agent');
|
||||||
|
const accept = request.headers.get('Accept') || '';
|
||||||
|
const agent = request.headers.get('User-Agent') || '';
|
||||||
|
// Explicit HTML wins; keep historic curl | sh entry point as a compatibility alias.
|
||||||
|
if (!accept.toLowerCase().includes('text/html') && /(?:curl|libcurl|wget|httpie)\//i.test(agent)) {
|
||||||
|
return respond(release.script, 'text/plain; charset=utf-8');
|
||||||
|
}
|
||||||
|
if (!release.pageTemplate) return respond('Homepage unavailable.\n', 'text/plain; charset=utf-8', 503);
|
||||||
|
return respond(renderPage(release.pageTemplate,snapshot),'text/html; charset=utf-8',snapshot.source.type === 'unavailable' ? 503 : 200);
|
||||||
|
}
|
||||||
|
// Only fingerprinted static assets are exposed; API typos never fall back to HTML.
|
||||||
|
if (/^\/assets\/[a-zA-Z0-9._-]+\.(?:css|js)$/.test(url.pathname)) {
|
||||||
|
const asset = await env.ASSETS.fetch(request);
|
||||||
|
if (!asset.ok) return respond('Not found.\n', 'text/plain; charset=utf-8', 404);
|
||||||
|
for (const [key, value] of asset.headers) headers.set(key, value);
|
||||||
|
for (const [key, value] of Object.entries(securityHeaders)) headers.set(key, value);
|
||||||
|
headers.set('Cache-Control', /\.[a-f0-9]{12}\./.test(url.pathname) ? 'public, max-age=31536000, immutable' : 'no-cache');
|
||||||
|
return new Response(request.method === 'HEAD' ? null : asset.body, {status: 200, headers});
|
||||||
|
}
|
||||||
|
return respond('Not found.\n', 'text/plain; charset=utf-8', 404);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import {parsePublicKeys} from './key-source.js';
|
||||||
|
|
||||||
|
// Parsing is deliberately not OpenPGP verification. The enrolled client verifies these exact bytes.
|
||||||
|
export const SIGNED_RELEASE_LIMITS = Object.freeze({documentBytes: 1024 * 1024, signatureBytes: 64 * 1024, publicKeyBytes: 1024 * 1024, maxKeys: 1000, maxSequence: 2147483647, clockSkewSeconds: 300});
|
||||||
|
export const SIGNATURE_SEPARATOR = '# detached-signature\n';
|
||||||
|
export const SIGNED_BUNDLE_MAX_BYTES = SIGNED_RELEASE_LIMITS.documentBytes + SIGNED_RELEASE_LIMITS.signatureBytes + SIGNATURE_SEPARATOR.length;
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const bytes = value => encoder.encode(value).length;
|
||||||
|
const listIdPattern = /^[A-Za-z0-9._-]{1,64}$/;
|
||||||
|
const decimal = /^(?:0|[1-9][0-9]{0,9})$/;
|
||||||
|
|
||||||
|
export function validateListId(value) {
|
||||||
|
if (typeof value !== 'string' || value === '-' || !listIdPattern.test(value)) throw Error('Invalid signed release list ID.');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
export function withoutKeyComments(keys) {
|
||||||
|
return keys.map((key, index) => ({...key, text: key.text.split(/[ \t]+/).slice(0, 2).join(' '), label: `Server key ${index + 1}`}));
|
||||||
|
}
|
||||||
|
export function validateSignature(signature) {
|
||||||
|
if (typeof signature !== 'string' || bytes(signature) > SIGNED_RELEASE_LIMITS.signatureBytes || !signature.endsWith('\n') || /[^\x09\x0a\x20-\x7e]/.test(signature)) throw Error('Invalid or oversized detached signature.');
|
||||||
|
const lines = signature.split('\n');
|
||||||
|
if (lines.shift() !== '-----BEGIN PGP SIGNATURE-----' || lines.pop() !== '' || lines.pop() !== '-----END PGP SIGNATURE-----') throw Error('Expected one ASCII-armored PGP detached signature.');
|
||||||
|
while (lines.length && /^[A-Za-z][A-Za-z0-9-]*: [\x20-\x7e]*$/.test(lines[0])) lines.shift();
|
||||||
|
if (lines.shift() !== '') throw Error('Invalid PGP armor header.');
|
||||||
|
if (lines.length && /^=[A-Za-z0-9+/]{4}$/.test(lines.at(-1))) lines.pop();
|
||||||
|
if (!lines.length || lines.some((line, i) => !/^[A-Za-z0-9+/]{1,76}={0,2}$/.test(line) || (i !== lines.length - 1 && line.includes('=')))) throw Error('Invalid PGP armor body.');
|
||||||
|
const payload = lines.join('');
|
||||||
|
try { if (!atob(payload).length) throw Error(); } catch { throw Error('Invalid PGP armor encoding.'); }
|
||||||
|
return signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseSignedDocument(document, {listId, now = Math.floor(Date.now() / 1000), checkFreshness = true, signal} = {}) {
|
||||||
|
if (typeof document !== 'string' || bytes(document) > SIGNED_RELEASE_LIMITS.documentBytes || !document.endsWith('\n')) throw Error('Invalid or oversized signed document.');
|
||||||
|
const match = /^# conf-sshd-signed-v1\n# list-id ([A-Za-z0-9._-]{1,64})\n# sequence ([1-9][0-9]{0,9})\n# issued-at (0|[1-9][0-9]{0,9})\n# expires-at (0|[1-9][0-9]{0,9})\n# revoke (yes|no)\n# key-count (0|[1-9][0-9]{0,3})\n# end-metadata\n([\s\S]*)$/.exec(document);
|
||||||
|
if (!match) throw Error('Invalid signed document metadata.');
|
||||||
|
const [, parsedListId, sequenceText, issuedText, expiresText, revokeText, countText, body] = match;
|
||||||
|
validateListId(parsedListId);
|
||||||
|
const sequence = Number(sequenceText), issuedAt = Number(issuedText), expiresAt = Number(expiresText), keyCount = Number(countText), revoke = revokeText === 'yes';
|
||||||
|
if (sequence > SIGNED_RELEASE_LIMITS.maxSequence || keyCount > SIGNED_RELEASE_LIMITS.maxKeys || expiresAt <= issuedAt || revoke !== (keyCount === 0)) throw Error('Inconsistent signed release policy.');
|
||||||
|
if (listId !== undefined && parsedListId !== validateListId(listId)) throw Error('Signed release list ID mismatch.');
|
||||||
|
if (checkFreshness && (!Number.isSafeInteger(now) || now < 0 || issuedAt > now + SIGNED_RELEASE_LIMITS.clockSkewSeconds || expiresAt <= now)) throw Error('Signed release is expired or not yet valid.');
|
||||||
|
const keys = withoutKeyComments(await parsePublicKeys(body, {signal}));
|
||||||
|
// Never normalize, strip comments, add padding, or alter a signed byte during verification/serving.
|
||||||
|
if (keys.length !== keyCount || keys.map(key => key.text + '\n').join('') !== body) throw Error('Signed key body must be canonical and match its key count.');
|
||||||
|
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(document)));
|
||||||
|
const documentSha256 = Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
return {document, keys, metadata: {listId: parsedListId, sequence, issuedAt, expiresAt, revoke, keyCount, documentSha256}};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseSignedBundle(bundle, options = {}) {
|
||||||
|
if (typeof bundle !== 'string' || bytes(bundle) > SIGNED_BUNDLE_MAX_BYTES) throw Error('Invalid or oversized signed bundle.');
|
||||||
|
const separator = bundle.indexOf(SIGNATURE_SEPARATOR);
|
||||||
|
if (separator < 0 || bundle.indexOf(SIGNATURE_SEPARATOR, separator + SIGNATURE_SEPARATOR.length) !== -1) throw Error('Signed bundle must contain exactly one detached-signature separator.');
|
||||||
|
const document = bundle.slice(0, separator), signature = bundle.slice(separator + SIGNATURE_SEPARATOR.length);
|
||||||
|
validateSignature(signature);
|
||||||
|
return {...await parseSignedDocument(document, options), signature, bundle};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prepare public bytes for an OFFLINE signer. This function never handles a private key. */
|
||||||
|
export async function makeSignedDocument(input, {listId, sequence, issuedAt, expiresAt, revoke = false}) {
|
||||||
|
validateListId(listId);
|
||||||
|
for (const [name, value] of Object.entries({sequence, issuedAt, expiresAt})) if (!Number.isSafeInteger(value) || !decimal.test(String(value))) throw Error(`Invalid ${name}.`);
|
||||||
|
if (typeof revoke !== 'boolean') throw Error('revoke must be an explicit boolean.');
|
||||||
|
const keys = withoutKeyComments(await parsePublicKeys(input));
|
||||||
|
const document = `# conf-sshd-signed-v1\n# list-id ${listId}\n# sequence ${sequence}\n# issued-at ${issuedAt}\n# expires-at ${expiresAt}\n# revoke ${revoke ? 'yes' : 'no'}\n# key-count ${keys.length}\n# end-metadata\n${keys.map(key => key.text + '\n').join('')}`;
|
||||||
|
await parseSignedDocument(document, {checkFreshness: false});
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A public endpoint may expose a document without a usable signature; OFF clients do not depend on it. */
|
||||||
|
export async function parsePublishedSignedRelease(document, signature = null, options = {}) {
|
||||||
|
const parsed = await parseSignedDocument(document, options);
|
||||||
|
let acceptedSignature = null;
|
||||||
|
try { if (signature !== null) acceptedSignature = validateSignature(signature); }
|
||||||
|
catch { if (options.requireSigned) throw Error('Invalid detached signature.'); }
|
||||||
|
if (options.requireSigned && acceptedSignature === null) throw Error('Missing detached signature.');
|
||||||
|
return {...parsed, signature: acceptedSignature, bundle: acceptedSignature === null ? null : document + SIGNATURE_SEPARATOR + acceptedSignature};
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import {validateSignature} from './signed-release.js';
|
||||||
|
|
||||||
|
export const escapeHTML = value => String(value).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
|
||||||
|
export function commandsFor(config) {
|
||||||
|
const fetch = `curl -fsSL '${config.baseUrl}/script.sh' | sh -s --`;
|
||||||
|
const installed = 'sh "$HOME/.conf-sshd/conf-sshd.sh"';
|
||||||
|
return [
|
||||||
|
{id:'install',title:'部署密钥并设置定期更新',description:`部署当前用户公钥并安装固定客户端;发现旧脚本先备份并保留本地配置。保留已有计划,新计划为 ${config.defaultCron || '*/15 * * * *'}。root 可按需安装 SSH Server 与 cron,非 root 缺少依赖时退出。`,command:`${fetch} --install --download-script -c`},
|
||||||
|
{id:'deploy-once',title:'部署一次,不设置定期更新',description:'与原版不带 -c 的用法一致:检查 SSH Server、按权限处理缺失依赖并部署公钥,不新增定时任务。',command:fetch},
|
||||||
|
{id:'keys-only',title:'只更新密钥,跳过 SSH Server 检查',description:'单独使用 -o,仅同步当前用户公钥,跳过 SSH Server 检查与软件包配置;不设置定时任务。日常同步优先使用下方已安装的本地脚本。',command:`${fetch} -o`},
|
||||||
|
{id:'root-on',title:'允许 root 密码登录并部署密钥',description:'须以 root 执行。设置 PermitRootLogin yes 并部署公钥;其他 SSH 认证设置仍可能限制密码登录,不自动重载服务。',command:`${fetch} -p yes`},
|
||||||
|
{id:'help',title:'查看帮助',description:'下载当前脚本并显示参数,不建立信任;执行前仍应检查下载内容。',command:`${fetch} -h`},
|
||||||
|
{id:'once',title:'使用本地脚本同步一次',description:'须先安装。使用固定客户端与本地信任设置,仅同步公钥,不检查 SSH Server、不更换脚本或定时计划。',command:`${installed} -o`,advanced:true},
|
||||||
|
{id:'preview',title:'预览公钥同步',description:'须先安装。使用本地客户端检查更新并预览将要修改的文件,不应用更新。',command:`${installed} -o --dry-run`,advanced:true},
|
||||||
|
{id:'cron-custom',title:'自定义定期更新计划',description:'须先安装。示例每 6 小时同步一次;cron 表达式必须加引号。只管理此脚本自己的计划。',command:`${installed} -c '0 */6 * * *'`,advanced:true},
|
||||||
|
{id:'cron-stop',title:'停止定期更新',description:'须先安装。仅移除此脚本的定时计划,保留本地脚本、公钥与信任配置。',command:`${installed} -c false`,advanced:true},
|
||||||
|
{id:'skip-sshd-install',title:'跳过 SSH Server 自动安装',description:'使用 -n 跳过 SSH Server 的检查与自动安装,继续部署公钥。需要严格的日常密钥同步时使用 -o。',command:`${fetch} -n`,advanced:true},
|
||||||
|
{id:'root-off',title:'禁止 root 密码登录并部署密钥',description:'须先安装并以 root 执行。校验并设置 PermitRootLogin prohibit-password,然后部署公钥;不自动重载服务。',command:`${installed} -p no`,advanced:true},
|
||||||
|
{id:'reinstall',title:'更新脚本:明确重新安装',description:'代替原 -u 自更新入口:主动下载并重装,备份旧脚本,保留本地配置与既有计划。首次安装或明确重装都会信任你执行的脚本,推荐先独立核验安装脚本;启用或更换信任须在服务器本地终端确认完整指纹。',command:`${fetch} --install --download-script`,advanced:true},
|
||||||
|
{id:'status',title:'查看本地安装状态',description:'须先安装。查看客户端与定时计划状态,不更改配置。',command:`${installed} --status`,advanced:true},
|
||||||
|
{id:'uninstall',title:'停止同步并卸载',description:'须先安装。移除脚本及其定时任务,保留公钥与 SSH 配置。',command:`${installed} --uninstall`,advanced:true},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function signingConfigured(config) {
|
||||||
|
return Boolean(config.signedRelease || config.clientDefaults?.gpgVerification === 'on');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pathsFor(config) {
|
||||||
|
const baseUrl = config.baseUrl || '';
|
||||||
|
return [
|
||||||
|
{id:'keys',title:'获取 SSH 公钥',description:'当前公钥文档。开启本地验签后,须与对应独立签名一并核验。',url:`${baseUrl}/ssh.keys`},
|
||||||
|
{id:'script',title:'获取 Shell 脚本',description:'显式脚本地址,浏览器与命令行均返回脚本文本。',url:`${baseUrl}/script.sh`},
|
||||||
|
{id:'root-script',title:'使用 Curl 获取脚本',description:'兼容原版根地址入口:Curl 默认请求返回 Shell 脚本;显式请求 text/html 时返回首页。',url:`${baseUrl}/`},
|
||||||
|
{id:'homepage',title:'使用浏览器访问本页面',description:'同一根地址在浏览器中展示首页,可查看发布状态与复制命令。',url:`${baseUrl}/`},
|
||||||
|
...(signingConfigured(config) ? [{id:'signature',title:'获取公钥文档独立签名',description:'与 /ssh.keys 原始字节配对。当前来源没有可用签名时返回 404,不代表公钥被撤销。',url:`${baseUrl}/ssh.keys.sig`},
|
||||||
|
{id:'certificate',title:'获取公开签名证书',description:'配置后提供公开证书,否则返回 404。安装时仍须按独立可信的完整指纹核验。',url:`${baseUrl}/public-key.gpg`}] : []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPaths(paths) {
|
||||||
|
return paths.map(path => `<article class="command-card path-card"><div class="card-heading"><h3>${escapeHTML(path.title)}</h3></div><p class="command-description">${escapeHTML(path.description)}</p>${codeBlock(`path-${path.id}`,path.url,`复制“${path.title}”地址`)}</article>`).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function codeBlock(id, text, label) {
|
||||||
|
return `<div class="code-block"><pre tabindex="0"><code id="${id}">${escapeHTML(text)}</code></pre><button type="button" class="copy-button" data-copy-target="${id}" aria-label="${escapeHTML(label)}" title="${escapeHTML(label)}"></button></div>`;
|
||||||
|
}
|
||||||
|
export function renderCommands(commands) {
|
||||||
|
const card = command => `<article class="command-card"><div class="card-heading"><h3>${escapeHTML(command.title)}</h3></div><p class="command-description">${escapeHTML(command.description)}</p>${codeBlock(`command-${command.id}`,command.command,`复制“${command.title}”命令`)}</article>`;
|
||||||
|
return commands.filter(c=>!c.advanced).map(card).join('\n') + '<details class="advanced-commands"><summary>更多用法与维护</summary><div class="command-list">' + commands.filter(c=>c.advanced).map(card).join('\n') + '</div></details>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The homepage projects metadata only. Key material remains confined to the APIs. */
|
||||||
|
export function renderPage(template, snapshot) {
|
||||||
|
const {revision, publishedAt, keys = [], commands = [], testFixture, repositoryUrl, source = {type:'repository'}} = snapshot;
|
||||||
|
const unavailable = source.type === 'unavailable' || source.type === 'pending';
|
||||||
|
const revoked = source.type === 'explicit-empty' || (!unavailable && snapshot.allowEmpty === true && (snapshot.keyCount ?? keys.length) === 0);
|
||||||
|
const sourceLabel = source.type === 'fallback' ? `备用源:${source.url}` : source.type === 'gist' ? `Gist:${source.url || '已配置来源'}` : source.type === 'git' ? `Git:${source.url || '已配置来源'}` : unavailable ? '等待可用公钥源' : 'Git 仓库清单';
|
||||||
|
const sourceStatus = source.type === 'pending' ? '待获取' : source.type === 'unavailable' ? '暂不可用' : revoked ? '明确撤销' : source.type === 'fallback' ? '备用源可用' : '可用';
|
||||||
|
const notice = unavailable ? '公钥源暂不可用,未发布撤销指令;现有服务器保留上次成功密钥。' : revoked ? '此发布明确撤销全部受管理公钥;客户端仍须按本地信任设置验证,手工公钥与已有会话不受影响。' : source.type === 'fallback' ? '当前使用按配置顺序选中的首个有效备用源;后续请求会重新获取,来源可用不表示客户端已验证。' : '此处仅展示来源与发布元数据;是否接受公钥,由服务器上的客户端按本地信任设置判断。';
|
||||||
|
// Presence is distinct from parsed document metadata and from cryptographic trust.
|
||||||
|
let signaturePresent = false;
|
||||||
|
if (!unavailable) {
|
||||||
|
if (typeof snapshot.signatureAvailable === 'boolean') signaturePresent = snapshot.signatureAvailable;
|
||||||
|
else if (snapshot.signedDocument && snapshot.keysText === snapshot.signedDocument) {
|
||||||
|
try { signaturePresent = Boolean(validateSignature(snapshot.signature)); } catch { /* No usable detached signature. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const certificatePresent = typeof snapshot.publicCertificateAvailable === 'boolean' ? snapshot.publicCertificateAvailable : typeof snapshot.publicCertificate === 'string' && snapshot.publicCertificate.length > 0;
|
||||||
|
const certificateStatus = certificatePresent ? '公开证书已提供(安装时核验)' : '未提供公开证书';
|
||||||
|
const signatureEnabled = signingConfigured(snapshot);
|
||||||
|
const signatureStatus = signaturePresent ? '签名包已提供(待客户端验证)' : '未提供签名包';
|
||||||
|
const keyCount = unavailable ? 0 : (snapshot.keyCount ?? keys.length);
|
||||||
|
const publicSource = {type:source.type, ...(source.url ? {url:source.url} : {})};
|
||||||
|
const publicData = {schema:2,revision,publishedAt,keyCount,sha256:snapshot.sha256 || null,keyRevision:snapshot.keyRevision || snapshot.sha256 || null,source:publicSource,sourceStatus,signatureEnabled,signaturePresent,signatureStatus,certificatePresent,certificateStatus,repositoryUrl,testFixture:Boolean(testFixture)};
|
||||||
|
const replacements = {
|
||||||
|
RELEASE_JSON:JSON.stringify(publicData).replace(/</g,'\\u003c').replace(/\u2028/g,'\\u2028').replace(/\u2029/g,'\\u2029'),
|
||||||
|
COMMANDS_HTML:renderCommands(commands),PATHS_HTML:renderPaths(pathsFor(snapshot)),REVISION:escapeHTML(revision.slice(0,12)),PUBLISHED_AT:escapeHTML(publishedAt),KEY_COUNT:String(keyCount),
|
||||||
|
REPOSITORY_URL:escapeHTML(repositoryUrl),RELEASE_LABEL:testFixture ? '测试预览' : unavailable ? '等待发布源' : '发布元数据',
|
||||||
|
PREVIEW_NOTICE:testFixture ? '<p class="preview-notice" role="note">测试预览:这里使用公开测试数据,不能部署到真实服务器。</p>' : '',
|
||||||
|
SIGNATURE_SECTION_START:'',SIGNATURE_SECTION_END:'',PATHS_INDEX:signatureEnabled ? '04' : '03',
|
||||||
|
VERIFICATION_DEFAULT:snapshot.clientDefaults?.gpgVerification === 'on' ? '本仓库默认开启客户端验签;既有服务器仍按本地已保存的设置执行。' : '客户端验签默认关闭,可在服务器本地明确启用;既有服务器仍按本地已保存的设置执行。',
|
||||||
|
CERTIFICATE_LINK:certificatePresent ? '<a class="text-link" href="/public-key.gpg">公开签名证书 <span aria-hidden="true">↗</span></a>' : '',
|
||||||
|
SOURCE_LABEL:escapeHTML(sourceLabel),SOURCE_STATUS:escapeHTML(sourceStatus),SIGNATURE_STATUS:escapeHTML(signatureStatus),CERTIFICATE_STATUS:escapeHTML(certificateStatus),CONTENT_REVISION:escapeHTML((snapshot.keyRevision || snapshot.sha256)?.slice(0,12) || '—'),KEYS_NOTICE:escapeHTML(notice),
|
||||||
|
};
|
||||||
|
if (!signatureEnabled) template = template.replace(/@@SIGNATURE_SECTION_START@@[\s\S]*?@@SIGNATURE_SECTION_END@@/g, '');
|
||||||
|
return template.replace(/@@([A-Z_]+)@@/g, (_, token) => {
|
||||||
|
if (!Object.hasOwn(replacements, token)) throw Error(`Unresolved page template token: ${token}`);
|
||||||
|
return replacements[token];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
name = "quickly-conf-ssh-worker"
|
name = "quickly-conf-ssh-worker"
|
||||||
main = "src/index.js"
|
main = "src/index.js"
|
||||||
compatibility_date = "2023-01-26"
|
compatibility_date = "2026-10-01"
|
||||||
|
|
||||||
|
[assets]
|
||||||
|
directory = "./public"
|
||||||
|
binding = "ASSETS"
|
||||||
|
run_worker_first = true
|
||||||
|
|||||||
+1471
-339
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"baseUrl": "https://ssh.lamgc.me",
|
||||||
|
"repositoryUrl": "https://github.com/LamGC/quickly-conf-sshd",
|
||||||
|
"defaultCron": "*/15 * * * *",
|
||||||
|
"allowEmpty": false,
|
||||||
|
"fallbackSources": [],
|
||||||
|
"keySource": {
|
||||||
|
"type": "git"
|
||||||
|
},
|
||||||
|
"clientDefaults": {
|
||||||
|
"gpgVerification": "off",
|
||||||
|
"fingerprint": "",
|
||||||
|
"pinMode": "primary",
|
||||||
|
"listId": "",
|
||||||
|
"email": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Add independently managed SERVER public keys here, one ordinary OpenSSH key per line.
|
||||||
|
# Do not copy private keys or automatically import GitHub account authentication keys.
|
||||||
|
# Empty production releases are rejected unless deployment.json explicitly sets allowEmpty=true.
|
||||||
Generated
+1561
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "quickly-conf-sshd",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test tests/*.test.mjs",
|
||||||
|
"build": "node scripts/build.mjs",
|
||||||
|
"build:test": "node scripts/build.mjs --test-fixture",
|
||||||
|
"check": "npm test && npm run build:test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"playwright-core": "1.58.2",
|
||||||
|
"wrangler": "4.149.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { readFile, writeFile, mkdir, rm, mkdtemp } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { resolve, dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
|
||||||
|
export const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
export const digest = data => createHash('sha256').update(data).digest('hex');
|
||||||
|
import { escapeHTML, commandsFor, renderCommands, renderPage } from '../cf-worker/src/view.js';
|
||||||
|
export { escapeHTML, commandsFor, renderCommands };
|
||||||
|
import { validateFallbackSources, parsePublicKeys, validateKeySource, validateSignedReleaseConfig } from '../cf-worker/src/key-source.js';
|
||||||
|
import {withoutKeyComments, parsePublishedSignedRelease, SIGNATURE_SEPARATOR} from '../cf-worker/src/signed-release.js';
|
||||||
|
import {checkSigningCertificate, validatePublicCertificate} from './signature-tools.mjs';
|
||||||
|
|
||||||
|
|
||||||
|
export function validateConfig(config) {
|
||||||
|
if (!config || typeof config !== 'object' || Array.isArray(config)) throw Error('Deployment configuration must be an object.');
|
||||||
|
for (const key of Object.keys(config)) if (!['baseUrl','repositoryUrl','defaultCron','allowEmpty','fallbackSources','keySource','signedRelease','clientDefaults'].includes(key)) throw Error(`Unknown configuration: ${key}`);
|
||||||
|
if (typeof config.baseUrl !== 'string' || !/^https:\/\/[a-zA-Z0-9.-]+(?::[0-9]+)?$/.test(config.baseUrl)) throw Error('baseUrl must be an HTTPS origin without paths, credentials, or shell metacharacters.');
|
||||||
|
const url = new URL(config.baseUrl);
|
||||||
|
if (url.origin !== config.baseUrl || !url.hostname.includes('.') || url.hostname.includes('..')) throw Error('baseUrl must be a canonical HTTPS origin.');
|
||||||
|
if (typeof config.repositoryUrl !== 'string' || !/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(config.repositoryUrl)) throw Error('repositoryUrl must identify a GitHub repository.');
|
||||||
|
if (typeof config.allowEmpty !== 'boolean') throw Error('allowEmpty must be an explicit boolean.');
|
||||||
|
validateFallbackSources(Object.hasOwn(config, 'fallbackSources') ? config.fallbackSources : []);
|
||||||
|
validateKeySource(config.keySource);
|
||||||
|
validateSignedReleaseConfig(config.signedRelease);
|
||||||
|
validateClientDefaults(config.clientDefaults);
|
||||||
|
const cron = config.defaultCron;
|
||||||
|
if (typeof cron !== 'string' || cron.trim() !== cron || !/^[0-9*,/ -]+$/.test(cron) || cron.split(/ +/).length !== 5) throw Error('defaultCron must contain five numeric cron fields.');
|
||||||
|
const bounds = [[0,59],[0,23],[1,31],[1,12],[0,7]];
|
||||||
|
cron.split(/ +/).forEach((field, i) => {
|
||||||
|
for (const part of field.split(',')) {
|
||||||
|
const match = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(part);
|
||||||
|
if (!match || (match[2] && (+match[2] < 1 || +match[2] > bounds[i][1] + 1))) throw Error('Invalid cron expression.');
|
||||||
|
if (match[1] !== '*') {
|
||||||
|
const [a,b = a] = match[1].split('-').map(Number);
|
||||||
|
if (a < bounds[i][0] || b > bounds[i][1] || a > b) throw Error('Cron value outside its field range.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateClientDefaults(value = {}) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !['gpgVerification', 'fingerprint', 'pinMode', 'listId', 'email'].includes(key))) throw Error('Invalid clientDefaults configuration.');
|
||||||
|
const result = {gpgVerification: value.gpgVerification ?? 'off', fingerprint: value.fingerprint ?? '', pinMode: value.pinMode ?? 'primary', listId: value.listId ?? '', email: value.email ?? ''};
|
||||||
|
if (!['off', 'on'].includes(result.gpgVerification) || !['primary', 'subkey'].includes(result.pinMode)) throw Error('Invalid client verification defaults.');
|
||||||
|
if (typeof result.fingerprint !== 'string' || (result.fingerprint !== '' && !/^[A-Fa-f0-9]{40}$/.test(result.fingerprint))) throw Error('Client fingerprint must be empty or a full 40-character fingerprint.');
|
||||||
|
if (typeof result.listId !== 'string' || result.listId === '-' || (result.listId !== '' && !/^[A-Za-z0-9._-]{1,64}$/.test(result.listId))) throw Error('Invalid client list ID.');
|
||||||
|
if (result.gpgVerification === 'on' && (!result.fingerprint || !result.listId)) throw Error('Enabled verification defaults require a full fingerprint and list ID.');
|
||||||
|
if (typeof result.email !== 'string' || (result.email !== '' && (!/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,63}$/.test(result.email) || result.email.length > 254))) throw Error('Client email must be empty or a safe public email address.');
|
||||||
|
result.fingerprint = result.fingerprint.toUpperCase();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseKeys(input) {
|
||||||
|
// Use the exact runtime parser, then additionally verify against the build's OpenSSH tools.
|
||||||
|
const keys = await parsePublicKeys(input);
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'conf-sshd-key-check-'));
|
||||||
|
try {
|
||||||
|
for (const key of keys) {
|
||||||
|
const file = join(directory, 'key.pub');
|
||||||
|
await writeFile(file, key.text + '\n', {mode:0o600});
|
||||||
|
try { execFileSync('ssh-keygen',['-l','-E','sha256','-f',file],{stdio:['ignore','pipe','pipe']}); }
|
||||||
|
catch { throw Error('ssh-keygen rejected a public key (OpenSSH client tools are required).'); }
|
||||||
|
}
|
||||||
|
} finally { await rm(directory,{recursive:true,force:true}); }
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeKeysText(keys, revision, allowEmpty) {
|
||||||
|
if (!/^[0-9a-f]{40}$/.test(revision)) throw Error('Release revision must be a 40-character Git commit SHA.');
|
||||||
|
if (typeof allowEmpty !== 'boolean') throw Error('allowEmpty must be an explicit boolean.');
|
||||||
|
if (!keys.length && !allowEmpty) throw Error('No server public keys configured. Add keys/authorized_keys, or explicitly allow an empty revocation release.');
|
||||||
|
const payload = withoutKeyComments(keys).map(key => key.text + '\n').join('');
|
||||||
|
const sha256 = digest(payload);
|
||||||
|
const text = `# conf-sshd-v1\n# revision ${revision}\n# key-count ${keys.length}\n# allow-empty ${allowEmpty ? 'yes' : 'no'}\n# sha256 ${sha256}\n# end-metadata\n${payload}`;
|
||||||
|
if (Buffer.byteLength(text) > 1024 * 1024) throw Error('Serialized key release exceeds client response limit.');
|
||||||
|
return {sha256,text};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function build({directory = root, outDirectory = join(directory,'cf-worker'), testFixture = false, revision, publishedAt} = {}) {
|
||||||
|
const config = validateConfig(JSON.parse(await readFile(join(directory,'deployment.json'),'utf8')));
|
||||||
|
const input = config.keySource?.type === 'gist' && !testFixture ? '' : await readFile(join(directory,testFixture ? 'tests/fixtures/keys.pub' : 'keys/authorized_keys'),'utf8').catch(error => {if (error.code === 'ENOENT' && !testFixture) return ''; throw error;});
|
||||||
|
let keys = [];
|
||||||
|
revision ||= process.env.GITHUB_SHA || execFileSync('git',['rev-parse','HEAD'],{cwd:directory,encoding:'utf8'}).trim();
|
||||||
|
if (!/^[0-9a-f]{40}$/.test(revision)) throw Error('Release revision must be a 40-character Git commit SHA.');
|
||||||
|
publishedAt ||= new Date().toISOString();
|
||||||
|
const fallbackSources = validateFallbackSources(config.fallbackSources ?? []);
|
||||||
|
const keySource = validateKeySource(config.keySource), signedRelease = validateSignedReleaseConfig(config.signedRelease);
|
||||||
|
const optional = async path => { try { return await readFile(join(directory, path), 'utf8'); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
|
||||||
|
let signed = null, publicCertificate = null;
|
||||||
|
if (keySource.type === 'git' && !testFixture) {
|
||||||
|
const documentFile = await optional('keys/ssh.keys'), bundleFile = await optional('keys/release.bundle');
|
||||||
|
if (documentFile !== null && bundleFile !== null) throw Error('Use keys/ssh.keys plus its optional signature OR keys/release.bundle, not both.');
|
||||||
|
if (documentFile !== null || bundleFile !== null) {
|
||||||
|
const separator = bundleFile?.indexOf(SIGNATURE_SEPARATOR) ?? -1;
|
||||||
|
if (bundleFile !== null && separator < 0) throw Error('release.bundle lacks its detached-signature separator.');
|
||||||
|
const document = documentFile ?? bundleFile.slice(0, separator);
|
||||||
|
const signature = documentFile !== null ? await optional('keys/ssh.keys.sig') : bundleFile.slice(separator + SIGNATURE_SEPARATOR.length);
|
||||||
|
signed = await parsePublishedSignedRelease(document, signature, {listId: signedRelease?.listId, checkFreshness: false});
|
||||||
|
keys = signed.keys;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!signed) keys = withoutKeyComments(await parseKeys(input));
|
||||||
|
if (signedRelease?.publicKeyFile) {
|
||||||
|
publicCertificate = validatePublicCertificate(await readFile(join(directory, signedRelease.publicKeyFile), 'utf8'));
|
||||||
|
if (signed && !signed.bundle) throw Error('Configured build signature verification requires a valid detached signature.');
|
||||||
|
await checkSigningCertificate(publicCertificate, signedRelease.fingerprint, signed?.bundle);
|
||||||
|
}
|
||||||
|
const allowEmpty = signed ? signed.metadata.revoke : config.allowEmpty;
|
||||||
|
const needsFallback = keySource.type === 'git' && keys.length === 0 && !allowEmpty && fallbackSources.length > 0;
|
||||||
|
const needsSource = keySource.type === 'gist' || needsFallback;
|
||||||
|
if (keySource.type === 'gist') keys = [];
|
||||||
|
const bundle = needsSource ? {sha256:null,text:null} : signed ? {sha256:digest(keys.map(key=>key.text+'\n').join('')),text:signed.document} : makeKeysText(keys,revision,allowEmpty);
|
||||||
|
const clientDefaults = validateClientDefaults(config.clientDefaults);
|
||||||
|
if (!testFixture && !needsSource && clientDefaults.gpgVerification === 'on') {
|
||||||
|
if (!signed?.signature) throw Error('Verification-enabled static Git releases require a signed document and detached signature.');
|
||||||
|
if (signed.metadata.listId !== clientDefaults.listId) throw Error('Client default list ID differs from the static signed document.');
|
||||||
|
if (signedRelease?.fingerprint && clientDefaults.pinMode === 'primary' && clientDefaults.fingerprint !== signedRelease.fingerprint) throw Error('Client primary fingerprint differs from the configured signing certificate.');
|
||||||
|
}
|
||||||
|
let script = await readFile(join(directory,'conf-sshd.sh'),'utf8');
|
||||||
|
for (const [name,value] of Object.entries({'BASE_URL':config.baseUrl,'DEFAULT_CRON':config.defaultCron})) {
|
||||||
|
if (!script.includes(`@@${name}@@`)) throw Error(`Script is missing @@${name}@@.`);
|
||||||
|
script = script.replaceAll(`@@${name}@@`,value);
|
||||||
|
}
|
||||||
|
for (const [name, value] of Object.entries({DEFAULT_GPG_VERIFY:clientDefaults.gpgVerification,DEFAULT_GPG_FINGERPRINT:clientDefaults.fingerprint,DEFAULT_GPG_PIN_MODE:clientDefaults.pinMode,DEFAULT_LIST_ID:clientDefaults.listId,DEFAULT_GPG_EMAIL:clientDefaults.email})) script = script.replaceAll(`@@${name}@@`, value);
|
||||||
|
if (/@@[A-Z_]+@@/.test(script)) throw Error('Unresolved script template token.');
|
||||||
|
if (!script.includes('# conf-sshd-script-v2')) throw Error('Missing script format marker.');
|
||||||
|
execFileSync('sh',['-n'],{input:script,stdio:['pipe','pipe','pipe']});
|
||||||
|
const release = {schema:1,revision,publishedAt,keyCount:keys.length,allowEmpty,sha256:bundle.sha256,keyRevision:signed?.metadata.documentSha256 ?? bundle.sha256,testFixture,baseUrl:config.baseUrl,repositoryUrl:config.repositoryUrl,fallbackSources,keySource,signedRelease,clientDefaults,publicCertificate,signedDocument:signed?.document ?? null,signature:signed?.signature ?? null,signatureAvailable:Boolean(signed?.signature),publicCertificateAvailable:Boolean(publicCertificate),signedBundle:signed?.bundle ?? null,signedMetadata:signed?.metadata ?? null,needsFallback,needsSource,keys,commands:commandsFor(config)};
|
||||||
|
let html = await readFile(join(directory,'site/index.html'),'utf8');
|
||||||
|
const publicDir = join(outDirectory,'public'), generatedDir = join(outDirectory,'generated');
|
||||||
|
await rm(publicDir,{recursive:true,force:true});
|
||||||
|
await mkdir(join(publicDir,'assets'),{recursive:true}); await mkdir(generatedDir,{recursive:true});
|
||||||
|
// Git files are true Worker Static Assets as well as exact router response bytes (never Pages).
|
||||||
|
if (!needsSource) await writeFile(join(publicDir,'ssh.keys'), signed?.document ?? bundle.text);
|
||||||
|
if (signed?.signature) await writeFile(join(publicDir,'ssh.keys.sig'), signed.signature);
|
||||||
|
if (publicCertificate) await writeFile(join(publicDir,'public-key.gpg'), publicCertificate);
|
||||||
|
for (const [file,ext] of [['style','css'],['app','js']]) {
|
||||||
|
const body = await readFile(join(directory,`site/assets/${file}.${ext}`));
|
||||||
|
const name = `${file}.${digest(body).slice(0,12)}.${ext}`;
|
||||||
|
await writeFile(join(publicDir,'assets',name),body);
|
||||||
|
html = html.replaceAll(`/assets/${file}.${ext}`,`/assets/${name}`);
|
||||||
|
}
|
||||||
|
release.pageTemplate = html;
|
||||||
|
await writeFile(join(publicDir,'index.html'),renderPage(html,{...release,source:{type:needsSource ? 'pending' : keys.length ? 'repository' : 'explicit-empty'}}));
|
||||||
|
await writeFile(join(generatedDir,'release.js'),`// Generated release; do not edit.\nexport default ${JSON.stringify({...release,script,keysText:signed?.document ?? bundle.text})};\n`);
|
||||||
|
await writeFile(join(generatedDir,'manifest.json'),JSON.stringify({...release,keys:undefined,commands:undefined,pageTemplate:undefined,publicCertificate:undefined,signedDocument:undefined,signature:undefined,signedBundle:undefined},null,2)+'\n');
|
||||||
|
return release;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
try {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.some(arg=>arg !== '--test-fixture')) throw Error('Usage: node scripts/build.mjs [--test-fixture]');
|
||||||
|
const release = await build({testFixture:args.includes('--test-fixture')});
|
||||||
|
console.log(`Built ${release.testFixture ? 'TEST FIXTURE — NEVER DEPLOY' : 'production'} release ${release.revision}: ${release.keyCount} key(s).`);
|
||||||
|
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Optional JSON webhook for a separately configured email delivery service.
|
||||||
|
// This sends deployment configuration, not observed server/fleet state.
|
||||||
|
import {readFile} from 'node:fs/promises';
|
||||||
|
import {resolve} from 'node:path';
|
||||||
|
import {fileURLToPath} from 'node:url';
|
||||||
|
|
||||||
|
export function notificationEnabled(value) {
|
||||||
|
if (value === undefined || value === '' || value === 'false') return false;
|
||||||
|
if (value === 'true') return true;
|
||||||
|
throw Error('ENABLE_MAIL_NOTIFICATION must be unset, false, or exactly true.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function httpsUrl(value, name) {
|
||||||
|
let url;
|
||||||
|
try { url = new URL(value); } catch { throw Error(`${name} must be an HTTPS URL.`); }
|
||||||
|
if (url.protocol !== 'https:' || url.username || url.password || url.hash) throw Error(`${name} must be HTTPS without URL credentials or fragments.`);
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deploymentPayload(config, env) {
|
||||||
|
if (!config || typeof config !== 'object' || Array.isArray(config)) throw Error('Deployment configuration must be an object.');
|
||||||
|
const status = env.DEPLOYMENT_STATUS;
|
||||||
|
if (!['success','failure','cancelled'].includes(status)) throw Error('DEPLOYMENT_STATUS must be success, failure, or cancelled.');
|
||||||
|
const phase = env.DEPLOYMENT_PHASE;
|
||||||
|
if (!['deployment','remote-verification','complete'].includes(phase)) throw Error('DEPLOYMENT_PHASE must identify the attempted deployment phase.');
|
||||||
|
if (!/^[0-9a-f]{40}$/.test(env.GITHUB_SHA || '')) throw Error('GITHUB_SHA must be an exact 40-hex commit SHA.');
|
||||||
|
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(env.GITHUB_REPOSITORY || '') || !/^\d+$/.test(env.GITHUB_RUN_ID || '')) throw Error('A valid GitHub repository and run ID are required.');
|
||||||
|
const mode = config.keySource?.type ?? 'git';
|
||||||
|
if (!['git','gist'].includes(mode)) throw Error('Unsupported deployment key source mode.');
|
||||||
|
const listId = config.signedRelease?.listId ?? null;
|
||||||
|
if (listId !== null && (typeof listId !== 'string' || !/^[A-Za-z0-9._-]{1,64}$/.test(listId))) throw Error('Invalid deployment list identity.');
|
||||||
|
const fallbackSources = config.fallbackSources ?? [];
|
||||||
|
if (!Array.isArray(fallbackSources) || fallbackSources.length > 5) throw Error('Invalid deployment fallback sources.');
|
||||||
|
const to = env.MAIL_NOTIFICATION_TO || null;
|
||||||
|
if (to !== null && (to.length > 254 || !/^[^\s@,<>]+@[^\s@,<>]+\.[^\s@,<>]+$/.test(to))) throw Error('MAIL_NOTIFICATION_TO must be one email address.');
|
||||||
|
return {
|
||||||
|
schema:1,
|
||||||
|
event:'worker-deployment',
|
||||||
|
status,
|
||||||
|
phase,
|
||||||
|
repository:env.GITHUB_REPOSITORY,
|
||||||
|
revision:env.GITHUB_SHA,
|
||||||
|
workflowRunUrl:`https://github.com/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`,
|
||||||
|
to,
|
||||||
|
configuration:{
|
||||||
|
baseUrl:httpsUrl(config.baseUrl, 'Deployment baseUrl'),
|
||||||
|
mode,
|
||||||
|
listId,
|
||||||
|
signedRelease:Boolean(config.signedRelease),
|
||||||
|
fallbackSources:fallbackSources.map(value => httpsUrl(value,'Deployment fallback source')),
|
||||||
|
},
|
||||||
|
configurationScope:'deployment configuration only; not measured server state',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyDeployment({env = process.env, config, fetchImpl = globalThis.fetch} = {}) {
|
||||||
|
if (!notificationEnabled(env.ENABLE_MAIL_NOTIFICATION)) return {sent:false,reason:'disabled'};
|
||||||
|
if (env.GITHUB_ACTIONS !== 'true' || env.CI !== 'true') throw Error('Deployment notification sending is allowed only in GitHub Actions CI.');
|
||||||
|
if (!env.MAIL_NOTIFICATION_WEBHOOK) throw Error('MAIL_NOTIFICATION_WEBHOOK is required when notifications are enabled.');
|
||||||
|
const endpoint = httpsUrl(env.MAIL_NOTIFICATION_WEBHOOK,'Notification webhook');
|
||||||
|
const payload = deploymentPayload(config,env);
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetchImpl(endpoint, {
|
||||||
|
method:'POST',
|
||||||
|
headers:{'content-type':'application/json'},
|
||||||
|
body:JSON.stringify(payload),
|
||||||
|
redirect:'error',
|
||||||
|
signal:AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Provider errors may contain secret URL tokens. Do not print them or retry
|
||||||
|
// automatically: a timed-out request may already have sent the email.
|
||||||
|
throw Error('Deployment notification request failed; check the webhook service.');
|
||||||
|
}
|
||||||
|
if (!response.ok) throw Error(`Deployment notification was rejected (HTTP ${response.status}).`);
|
||||||
|
// Do not log response bodies, the recipient, or the secret webhook URL.
|
||||||
|
return {sent:true};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
try {
|
||||||
|
if (process.argv.length !== 2) throw Error('Usage: node scripts/notify-deployment.mjs');
|
||||||
|
let config;
|
||||||
|
if (notificationEnabled(process.env.ENABLE_MAIL_NOTIFICATION)) {
|
||||||
|
try { config = JSON.parse(await readFile(new URL('../deployment.json',import.meta.url),'utf8')); }
|
||||||
|
catch { throw Error('Cannot read deployment notification configuration.'); }
|
||||||
|
}
|
||||||
|
const result = await notifyDeployment({config});
|
||||||
|
console.log(result.sent ? 'Deployment notification webhook accepted the request.' : 'Deployment notification disabled.');
|
||||||
|
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// This preparation tool only reads PUBLIC SSH keys and writes the exact bytes to sign offline.
|
||||||
|
// It never reads a signing private key, invokes gpg signing, or uploads anything.
|
||||||
|
import {readFile, writeFile} from 'node:fs/promises';
|
||||||
|
import {resolve} from 'node:path';
|
||||||
|
import {fileURLToPath} from 'node:url';
|
||||||
|
import {makeSignedDocument, parseSignedDocument, SIGNATURE_SEPARATOR, validateSignature} from '../cf-worker/src/signed-release.js';
|
||||||
|
|
||||||
|
export async function prepare(argv) {
|
||||||
|
const options = {};
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const key = argv[i];
|
||||||
|
if (key === '--revoke') { options.revoke = true; continue; }
|
||||||
|
if (!['--input', '--output', '--list-id', '--sequence', '--issued-at', '--expires-at', '--signature', '--bundle'].includes(key) || i + 1 >= argv.length || Object.hasOwn(options, key)) throw Error('Usage: node scripts/prepare-key-release.mjs --input PUBLIC_KEYS --output DOCUMENT --list-id ID --sequence N --issued-at EPOCH --expires-at EPOCH [--revoke]\nOr: --input DOCUMENT --signature SIGNATURE --bundle BUNDLE (pack only after offline signing).');
|
||||||
|
options[key] = argv[++i];
|
||||||
|
}
|
||||||
|
if (options['--bundle']) {
|
||||||
|
if (!options['--input'] || !options['--signature'] || Object.keys(options).some(key => !['--input', '--signature', '--bundle'].includes(key))) throw Error('Packing accepts only --input DOCUMENT --signature SIGNATURE --bundle BUNDLE.');
|
||||||
|
const document = await readFile(options['--input'], 'utf8'), signature = await readFile(options['--signature'], 'utf8');
|
||||||
|
await parseSignedDocument(document, {checkFreshness: false});
|
||||||
|
validateSignature(signature);
|
||||||
|
await writeFile(options['--bundle'], document + SIGNATURE_SEPARATOR + signature, {flag: 'wx', mode: 0o600});
|
||||||
|
return 'Packed exact document and detached signature. Cryptographic verification is still required.';
|
||||||
|
}
|
||||||
|
if (!options['--input'] || !options['--output'] || !options['--list-id'] || options['--signature']) throw Error('Preparation requires --input, --output, --list-id, --sequence, --issued-at and --expires-at.');
|
||||||
|
const numbers = {};
|
||||||
|
for (const [key, field] of [['--sequence','sequence'], ['--issued-at','issuedAt'], ['--expires-at','expiresAt']]) {
|
||||||
|
if (!/^(?:0|[1-9][0-9]{0,9})$/.test(options[key] ?? '')) throw Error(`${key} must be a canonical decimal integer.`);
|
||||||
|
numbers[field] = Number(options[key]);
|
||||||
|
}
|
||||||
|
const document = await makeSignedDocument(await readFile(options['--input'], 'utf8'), {listId: options['--list-id'], ...numbers, revoke: Boolean(options.revoke)});
|
||||||
|
await writeFile(options['--output'], document, {flag: 'wx', mode: 0o600});
|
||||||
|
return 'Prepared comment-free bytes. Sign this exact file offline with an armored detached signature; never rewrite it afterward.';
|
||||||
|
}
|
||||||
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
try { console.log(await prepare(process.argv.slice(2))); } catch (error) { console.error(error.message); process.exitCode = 1; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {mkdtemp, writeFile, rm} from 'node:fs/promises';
|
||||||
|
import {tmpdir} from 'node:os';
|
||||||
|
import {join} from 'node:path';
|
||||||
|
import {execFileSync} from 'node:child_process';
|
||||||
|
import {SIGNED_RELEASE_LIMITS, parseSignedBundle} from '../cf-worker/src/signed-release.js';
|
||||||
|
|
||||||
|
export function validatePublicCertificate(certificate) {
|
||||||
|
if (typeof certificate !== 'string' || Buffer.byteLength(certificate) > SIGNED_RELEASE_LIMITS.publicKeyBytes || !/^-----BEGIN PGP PUBLIC KEY BLOCK-----\r?\n[\s\S]+\r?\n-----END PGP PUBLIC KEY BLOCK-----\r?\n?$/.test(certificate) || /[^\x09\x0a\x0d\x20-\x7e]/.test(certificate) || (certificate.match(/-----BEGIN /g) || []).length !== 1) throw Error('Expected one bounded ASCII-armored PUBLIC OpenPGP certificate, never private material.');
|
||||||
|
return certificate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uses an ephemeral public-only keyring. Never loads the operator's keyring, signs, or retrieves keys. */
|
||||||
|
export async function checkSigningCertificate(certificate, fingerprint, signed = null) {
|
||||||
|
validatePublicCertificate(certificate);
|
||||||
|
if (typeof fingerprint !== 'string' || !/^[A-Fa-f0-9]{40}$/.test(fingerprint)) throw Error('A full primary signing fingerprint is required.');
|
||||||
|
fingerprint = fingerprint.toUpperCase();
|
||||||
|
const home = await mkdtemp(join(tmpdir(), 'conf-sshd-public-verify-'));
|
||||||
|
const args = ['--no-options', '--homedir', home, '--batch', '--no-tty', '--no-autostart', '--no-auto-key-retrieve', '--auto-key-locate', 'clear'];
|
||||||
|
const gpg = more => {try {return execFileSync('gpg', [...args, ...more], {encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 15000, maxBuffer: 4 * 1024 * 1024});} catch {throw Error('GPG public verification command failed.');}};
|
||||||
|
try {
|
||||||
|
const certPath = join(home, 'certificate.asc');
|
||||||
|
await writeFile(certPath, certificate, {mode: 0o600});
|
||||||
|
const packets = gpg(['--list-packets', certPath]);
|
||||||
|
if (/:secret (?:sub )?key packet:/.test(packets)) throw Error('Secret key packets are not accepted.');
|
||||||
|
const listing = gpg(['--with-colons', '--show-keys', certPath]);
|
||||||
|
const records = listing.split('\n').map(line => line.split(':'));
|
||||||
|
const primaries = records.filter(record => record[0] === 'pub');
|
||||||
|
if (primaries.length !== 1 || records.some(record => record[0] === 'sec' || record[0] === 'ssb')) throw Error('The public certificate must contain exactly one primary identity.');
|
||||||
|
if (['r', 'e', 'd'].includes(primaries[0][1]) || (primaries[0][6] && Number(primaries[0][6]) <= Math.floor(Date.now()/1000))) throw Error('Signing public certificate is expired, revoked, or disabled.');
|
||||||
|
const primaryIndex = records.findIndex(record => record[0] === 'pub');
|
||||||
|
const actual = records.slice(primaryIndex + 1).find(record => record[0] === 'fpr')?.[9];
|
||||||
|
if (actual !== fingerprint) throw Error('Signing public certificate fingerprint mismatch.');
|
||||||
|
if (!signed) return fingerprint;
|
||||||
|
const parsed = await parseSignedBundle(signed, {checkFreshness: false});
|
||||||
|
const keyring = join(home, 'public.gpg');
|
||||||
|
gpg(['--output', keyring, '--dearmor', certPath]);
|
||||||
|
const documentPath = join(home, 'document'), signaturePath = join(home, 'signature.asc');
|
||||||
|
await writeFile(documentPath, parsed.document, {mode: 0o600});
|
||||||
|
await writeFile(signaturePath, parsed.signature, {mode: 0o600});
|
||||||
|
let status;
|
||||||
|
try { status = gpg(['--no-default-keyring', '--keyring', keyring, '--status-fd', '1', '--verify', signaturePath, documentPath]); }
|
||||||
|
catch { throw Error('Detached OpenPGP signature verification failed.'); }
|
||||||
|
validatePayloadSignatureStatus(status, fingerprint);
|
||||||
|
return fingerprint;
|
||||||
|
} finally { await rm(home, {recursive: true, force: true}); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePayloadSignatureStatus(output, fingerprint, now = Math.floor(Date.now() / 1000)) {
|
||||||
|
const records = output.split(/\r?\n/).filter(line => line.startsWith('[GNUPG:] ')).map(line => line.slice(9).split(/ +/));
|
||||||
|
const rejected = new Set(['BADSIG','ERRSIG','NO_PUBKEY','EXPSIG','EXPKEYSIG','REVKEYSIG','KEYEXPIRED','SIGEXPIRED','KEYREVOKED','FAILURE','ERROR','NODATA','BADARMOR']);
|
||||||
|
if (records.some(record => rejected.has(record[0]))) throw Error('Detached signature is invalid, expired, revoked, or unverifiable.');
|
||||||
|
const valid = records.filter(record => record[0] === 'VALIDSIG');
|
||||||
|
if (valid.length !== 1) throw Error('Expected exactly one valid detached signature.');
|
||||||
|
const [, signer, , timestamp, expires, version, , , hash, signatureClass, primary] = valid[0];
|
||||||
|
if (valid[0].length !== 11 || !/^[A-Fa-f0-9]{40}$/.test(signer) || !/^[A-Fa-f0-9]{40}$/.test(primary || '') || version !== '4' || !['8','9','10'].includes(hash) || signatureClass !== '00') throw Error('Payload requires an OpenPGP v4 binary SHA-256/384/512 signature with a full primary fingerprint.');
|
||||||
|
if (!/^\d+$/.test(timestamp) || !/^\d+$/.test(expires) || (Number(expires) !== 0 && Number(expires) <= now)) throw Error('Detached signature has expired or invalid timestamps.');
|
||||||
|
if (primary.toUpperCase() !== fingerprint.toUpperCase()) throw Error('Detached signature primary identity is not pinned.');
|
||||||
|
return {signerFingerprint:signer.toUpperCase(), primaryFingerprint:primary.toUpperCase()};
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Explicit external integration test. npm test imports this module but never
|
||||||
|
// invokes its network entry point. The public identity is a test-only fixture.
|
||||||
|
import {spawnSync} from 'node:child_process';
|
||||||
|
import {appendFileSync, chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
|
||||||
|
import {tmpdir} from 'node:os';
|
||||||
|
import {dirname, join, resolve} from 'node:path';
|
||||||
|
import {fileURLToPath} from 'node:url';
|
||||||
|
|
||||||
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const MAX_CERTIFICATE = 1024 * 1024;
|
||||||
|
const ATTEMPT_MS = 15_000;
|
||||||
|
// Deadline for starting/continuing network lookups. Each local verifier and
|
||||||
|
// private-agent cleanup also has its own timeout; CI supplies the hard job cap.
|
||||||
|
const NETWORK_BUDGET_MS = 120_000;
|
||||||
|
const MAX_ATTEMPTS = 2;
|
||||||
|
const fingerprint = value => typeof value === 'string' && /^[A-F0-9]{40}$/.test(value);
|
||||||
|
|
||||||
|
export function validateFixture(input) {
|
||||||
|
if (!input || typeof input !== 'object' || !input.testOnlyNotice?.includes('integration-test identity only') ||
|
||||||
|
typeof input.email !== 'string' || input.email.length > 254 || !/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,63}$/.test(input.email) ||
|
||||||
|
!fingerprint(input.primaryFingerprint) || !fingerprint(input.signingSubkeyFingerprint) ||
|
||||||
|
input.primaryFingerprint === input.signingSubkeyFingerprint || !Array.isArray(input.keyservers) ||
|
||||||
|
input.keyservers.length < 1 || input.keyservers.length > 4 ||
|
||||||
|
input.keyservers.some(value => typeof value !== 'string' || !/^hkps:\/\/[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?(?::[0-9]{1,5})?$/.test(value)) ||
|
||||||
|
new Set(input.keyservers).size !== input.keyservers.length) throw Error('Invalid public integration-test fixture.');
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyFailure(result) {
|
||||||
|
if (result.error?.code === 'ETIMEDOUT' || result.signal === 'SIGKILL') return 'timeout';
|
||||||
|
if (result.error?.code === 'ENOENT') return 'missing-tool';
|
||||||
|
if (result.error) return 'process-error';
|
||||||
|
const diagnostic = String(result.stderr ?? '').toLowerCase();
|
||||||
|
if (/no dirmngr|can.?t create socket|failed to start dirmngr|operation not permitted/.test(diagnostic)) return 'local-agent-unavailable';
|
||||||
|
if (/timed? out|timeout/.test(diagnostic)) return 'timeout';
|
||||||
|
if (/certificate.*(failed|invalid)|tls.*(failed|error)|not trusted/.test(diagnostic)) return 'transport-security-error';
|
||||||
|
if (/network is unreachable|no route to host|could not resolve|name or service not known|network error|connection refused/.test(diagnostic)) return 'network-unavailable';
|
||||||
|
if (/no data|not found|no public key/.test(diagnostic)) return 'key-not-found';
|
||||||
|
return 'gpg-error';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function execute(command, args, {home, timeout = ATTEMPT_MS} = {}) {
|
||||||
|
return spawnSync(command, args, {
|
||||||
|
encoding: 'utf8', timeout, killSignal: 'SIGKILL', maxBuffer: MAX_CERTIFICATE + 64 * 1024,
|
||||||
|
env: {...process.env, LC_ALL: 'C', ...(home ? {HOME: home, GNUPGHOME: home} : {})},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract just the production verifier functions. Never source or run the
|
||||||
|
// installer, its argument parser, dependencies installer, or SSH configuration.
|
||||||
|
export function verifierHarness(clientSource) {
|
||||||
|
const names = ['new_gpg_home', 'gpg_local', 'import_pinned_key'];
|
||||||
|
const functions = names.map(name => {
|
||||||
|
const matches = [...clientSource.matchAll(new RegExp(`^${name}\\(\\) \\{\\n[\\s\\S]*?^\\}\\n`, 'gm'))];
|
||||||
|
if (matches.length !== 1) throw Error(`Cannot extract client verifier function: ${name}.`);
|
||||||
|
return matches[0][0];
|
||||||
|
}).join('\n');
|
||||||
|
return `#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
umask 077
|
||||||
|
LC_ALL=C
|
||||||
|
export LC_ALL
|
||||||
|
state=$1
|
||||||
|
certificate=$2
|
||||||
|
pinned_fingerprint=$3
|
||||||
|
pin_mode=$4
|
||||||
|
gpg_home=
|
||||||
|
temps=
|
||||||
|
fail() { printf 'client-certificate-rejected\\n' >&2; exit 1; }
|
||||||
|
need() { command -v "$1" >/dev/null 2>&1 || fail; }
|
||||||
|
new_temp() { TEMP=$(mktemp "$1/verify.XXXXXX"); }
|
||||||
|
${functions}
|
||||||
|
import_pinned_key "$certificate"
|
||||||
|
printf '%s\\n' "$certificate_primary"
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCertificate(certificate, config, {
|
||||||
|
clientSource = readFileSync(join(repositoryRoot, 'conf-sshd.sh'), 'utf8'), runner = execute,
|
||||||
|
} = {}) {
|
||||||
|
if (typeof certificate !== 'string' || !certificate.length || Buffer.byteLength(certificate) > MAX_CERTIFICATE) return {ok: false, status: 'invalid-certificate-size'};
|
||||||
|
const home = mkdtempSync(join(tmpdir(), 'conf-sshd-certificate-check-'));
|
||||||
|
chmodSync(home, 0o700);
|
||||||
|
try {
|
||||||
|
const keyFile = join(home, 'public.asc'), harness = join(home, 'verify.sh');
|
||||||
|
writeFileSync(keyFile, certificate, {mode: 0o600});
|
||||||
|
writeFileSync(harness, verifierHarness(clientSource), {mode: 0o600});
|
||||||
|
for (const mode of ['primary', 'subkey']) {
|
||||||
|
// One clean home per pin mode, even though the function itself isolates its ring.
|
||||||
|
const state = mkdtempSync(join(home, `${mode}-`));
|
||||||
|
const pin = mode === 'primary' ? config.primaryFingerprint : config.signingSubkeyFingerprint;
|
||||||
|
const result = runner('/bin/sh', [harness, state, keyFile, pin, mode], {home: state, timeout: ATTEMPT_MS});
|
||||||
|
if (result.status !== 0 || result.error || result.stdout?.trim() !== config.primaryFingerprint) {
|
||||||
|
return {ok: false, status: result.error ? classifyFailure(result) : 'certificate-pin-or-capability-rejected', mode};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {ok: true, status: 'validated-primary-and-subkey'};
|
||||||
|
} finally {
|
||||||
|
// The extracted verifier uses --no-autostart and a public-only keyring.
|
||||||
|
rmSync(home, {recursive: true, force: true});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discoveryProbes(config) {
|
||||||
|
return [
|
||||||
|
{source: 'wkd', queryMode: 'email', args: ['--auto-key-locate', 'clear,wkd', '--locate-external-keys', '--', config.email]},
|
||||||
|
...config.keyservers.map(server => ({
|
||||||
|
source: server, queryMode: 'primary',
|
||||||
|
args: ['--auto-key-locate', 'clear', '--keyserver', server, '--recv-keys', config.primaryFingerprint],
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runDiscovery(config, {runner = execute, validator = validateCertificate, now = Date.now} = {}) {
|
||||||
|
validateFixture(config);
|
||||||
|
const start = now(), deadline = start + NETWORK_BUDGET_MS, results = [];
|
||||||
|
for (const probe of discoveryProbes(config)) {
|
||||||
|
let status = 'budget-exhausted', attempts = 0;
|
||||||
|
for (; attempts < MAX_ATTEMPTS;) {
|
||||||
|
const remaining = deadline - now();
|
||||||
|
if (remaining <= 0) { status = 'budget-exhausted'; break; }
|
||||||
|
attempts++;
|
||||||
|
const home = mkdtempSync(join(tmpdir(), 'conf-sshd-live-discovery-'));
|
||||||
|
chmodSync(home, 0o700);
|
||||||
|
try {
|
||||||
|
const base = ['--batch', '--no-options', '--homedir', home, '--no-tty', '--no-auto-key-retrieve'];
|
||||||
|
const fetched = runner('gpg', [...base, '--status-fd', '1', ...probe.args], {home, timeout: Math.min(ATTEMPT_MS, remaining)});
|
||||||
|
if (fetched.status !== 0 || fetched.error) {
|
||||||
|
status = classifyFailure(fetched);
|
||||||
|
} else {
|
||||||
|
const exportRemaining = deadline - now();
|
||||||
|
if (exportRemaining <= 0) { status = 'budget-exhausted'; break; }
|
||||||
|
const exported = runner('gpg', [...base, '--no-autostart', '--auto-key-locate', 'clear', '--armor', '--export'], {home, timeout: Math.min(ATTEMPT_MS, exportRemaining)});
|
||||||
|
if (exported.status !== 0 || exported.error) status = `export-${classifyFailure(exported)}`;
|
||||||
|
else {
|
||||||
|
const validation = validator(exported.stdout, config);
|
||||||
|
status = validation.ok ? 'passed' : validation.status;
|
||||||
|
// A fetched but invalid certificate is a security failure, not a reason
|
||||||
|
// to hide the result by retrying until the server returns another key.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Only services associated with this newly created private home are stopped.
|
||||||
|
runner('gpgconf', ['--homedir', home, '--kill', 'all'], {home, timeout: 5000});
|
||||||
|
rmSync(home, {recursive: true, force: true});
|
||||||
|
}
|
||||||
|
if (!['timeout', 'network-unavailable', 'key-not-found', 'gpg-error'].includes(status)) break;
|
||||||
|
}
|
||||||
|
results.push({source: probe.source, queryMode: probe.queryMode, status, attempts,
|
||||||
|
primaryFingerprint: config.primaryFingerprint, signingSubkeyFingerprint: config.signingSubkeyFingerprint});
|
||||||
|
}
|
||||||
|
return {schema: 'conf-sshd-live-discovery-v1', status: results.every(result => result.status === 'passed') ? 'passed' : 'failed', results};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summaryMarkdown(report) {
|
||||||
|
return ['## Public GPG discovery integration test', '',
|
||||||
|
`Overall: ${report.status}. Every source must pass; infrastructure failures are not skips.`, '',
|
||||||
|
...report.results.map(result => `- ${result.source} (${result.queryMode}): ${result.status}; attempts ${result.attempts}; primary ${result.primaryFingerprint ?? 'unavailable'}; signing subkey ${result.signingSubkeyFingerprint ?? 'unavailable'}`), '',
|
||||||
|
'This tests public certificate discovery and the client pin/capability checks. No user-signed key-list payload is tested, and no SSH or persistent user trust settings are changed.', ''].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function main() {
|
||||||
|
let report;
|
||||||
|
try {
|
||||||
|
const config = validateFixture(JSON.parse(readFileSync(join(repositoryRoot, 'tests/fixtures/live-discovery.json'), 'utf8')));
|
||||||
|
const missing = ['gpg', 'dirmngr', 'gpgconf'].find(command => execute(command, ['--version'], {timeout: 5000}).status !== 0);
|
||||||
|
report = missing ? {schema: 'conf-sshd-live-discovery-v1', status: 'failed', results: [{source: 'local-tools', queryMode: 'none', status: 'missing-tool', attempts: 0,
|
||||||
|
primaryFingerprint: config.primaryFingerprint, signingSubkeyFingerprint: config.signingSubkeyFingerprint}]} : runDiscovery(config);
|
||||||
|
} catch {
|
||||||
|
report = {schema: 'conf-sshd-live-discovery-v1', status: 'failed', results: [{source: 'local-harness', queryMode: 'none', status: 'fixture-or-harness-error', attempts: 0}]};
|
||||||
|
}
|
||||||
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||||
|
if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryMarkdown(report));
|
||||||
|
return report.status === 'passed' ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = main();
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// This verifier and the workflow are trusted repository code, not an immutable
|
||||||
|
// security boundary. Protect changes to both; trusted keys/pins live in Actions
|
||||||
|
// variables outside this commit. No npm packages or repository imports are used.
|
||||||
|
import {spawnSync} from 'node:child_process';
|
||||||
|
import {mkdtemp, writeFile, rm} from 'node:fs/promises';
|
||||||
|
import {tmpdir} from 'node:os';
|
||||||
|
import {join, resolve} from 'node:path';
|
||||||
|
import {fileURLToPath} from 'node:url';
|
||||||
|
|
||||||
|
export function enabledFlag(value, name = 'REQUIRE_GPG_SIGNATURE') {
|
||||||
|
if (value === undefined || value === '' || value === 'false') return false;
|
||||||
|
if (value === 'true') return true;
|
||||||
|
throw Error(`${name} must be unset, false, or exactly true.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFingerprints(value) {
|
||||||
|
const values = typeof value === 'string' ? value.trim().split(/[\s,]+/).filter(Boolean) : [];
|
||||||
|
if (!values.length || values.some(value => !/^[0-9a-fA-F]{40}$/.test(value))) {
|
||||||
|
throw Error('GPG_ALLOWED_PRIMARY_FINGERPRINTS must contain full 40-hex OpenPGP v4 primary fingerprints.');
|
||||||
|
}
|
||||||
|
return new Set(values.map(value => value.toUpperCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifySignatureStatus(output, allowed, now = Math.floor(Date.now() / 1000)) {
|
||||||
|
const records = output.split(/\r?\n/).filter(line => line.startsWith('[GNUPG:] ')).map(line => line.slice(9).split(/ +/));
|
||||||
|
const rejected = new Set(['BADSIG','ERRSIG','NO_PUBKEY','EXPSIG','EXPKEYSIG','REVKEYSIG','KEYEXPIRED','SIGEXPIRED','KEYREVOKED','FAILURE','ERROR','NODATA','BADARMOR']);
|
||||||
|
if (records.some(record => rejected.has(record[0]))) throw Error('Commit signature is invalid, expired, revoked, or unverifiable.');
|
||||||
|
const valid = records.filter(record => record[0] === 'VALIDSIG');
|
||||||
|
if (valid.length !== 1) throw Error('Expected exactly one valid OpenPGP commit signature.');
|
||||||
|
const [, signer, , timestamp, expires, version, , , hash, signatureClass, primary] = valid[0];
|
||||||
|
if (valid[0].length !== 11 || !/^[A-Fa-f0-9]{40}$/.test(signer) || !/^[A-Fa-f0-9]{40}$/.test(primary || '') || version !== '4' || !['8','9','10'].includes(hash) || signatureClass !== '00') {
|
||||||
|
throw Error('Commit requires an OpenPGP v4 SHA-256/384/512 signature with full primary fingerprint.');
|
||||||
|
}
|
||||||
|
if (!/^\d+$/.test(timestamp) || !/^\d+$/.test(expires) || (Number(expires) !== 0 && Number(expires) <= now)) throw Error('Commit signature has expired or invalid timestamps.');
|
||||||
|
if (!allowed.has(primary.toUpperCase())) throw Error('Commit signer primary fingerprint is not pinned.');
|
||||||
|
return {signerFingerprint:signer.toUpperCase(), primaryFingerprint:primary.toUpperCase()};
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args, {cwd, env, input} = {}) {
|
||||||
|
const result = spawnSync(command, args, {cwd, env, input, encoding:'utf8', timeout:30_000, maxBuffer:4 * 1024 * 1024});
|
||||||
|
// Never relay GPG key text/UIDs or arbitrary subprocess errors into CI logs.
|
||||||
|
if (result.error || result.status !== 0 || result.signal) throw Error(`${command === 'git' ? 'Git' : 'GPG'} verification command failed.`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyCommitSignature({env = process.env, directory = process.cwd()} = {}) {
|
||||||
|
const required = enabledFlag(env.REQUIRE_GPG_SIGNATURE);
|
||||||
|
const revision = env.GITHUB_SHA;
|
||||||
|
if (!/^[0-9a-f]{40}$/.test(revision || '')) throw Error('GITHUB_SHA must be the exact 40-hex commit SHA.');
|
||||||
|
const cleanEnv = {...env, GIT_CONFIG_NOSYSTEM:'1', GIT_CONFIG_GLOBAL:'/dev/null', GIT_NO_REPLACE_OBJECTS:'1'};
|
||||||
|
const head = run('git', ['-c','core.fsmonitor=false','rev-parse','--verify','HEAD'], {cwd:directory,env:cleanEnv}).stdout.trim();
|
||||||
|
if (head !== revision) throw Error('Checked-out HEAD does not match GITHUB_SHA.');
|
||||||
|
if (!required) return {required:false,revision};
|
||||||
|
const allowed = parseFingerprints(env.GPG_ALLOWED_PRIMARY_FINGERPRINTS);
|
||||||
|
const publicKeys = env.GPG_PUBLIC_KEYS;
|
||||||
|
if (typeof publicKeys !== 'string' || !publicKeys.includes('-----BEGIN PGP PUBLIC KEY BLOCK-----') || publicKeys.includes('PRIVATE KEY')) throw Error('GPG_PUBLIC_KEYS must contain externally configured armored public keys only.');
|
||||||
|
const home = await mkdtemp(join(tmpdir(), 'conf-sshd-gpg-'));
|
||||||
|
try {
|
||||||
|
const gpgEnv = {...cleanEnv, GNUPGHOME:home};
|
||||||
|
const options = ['--no-options','--homedir',home,'--batch','--no-tty','--no-autostart','--no-auto-key-retrieve','--auto-key-locate','clear'];
|
||||||
|
// Decode into an isolated legacy public keyring without importing keys or
|
||||||
|
// contacting gpg-agent. This also avoids starting secret-key services in CI.
|
||||||
|
const listing = run('gpg', [...options,'--with-colons','--show-keys'], {env:gpgEnv,input:publicKeys}).stdout;
|
||||||
|
if (!/^pub:/m.test(listing) || /^(?:sec|ssb):/m.test(listing)) throw Error('Only public verification keys are allowed.');
|
||||||
|
run('gpg', [...options,'--output',join(home,'public.gpg'),'--dearmor'], {env:gpgEnv,input:publicKeys});
|
||||||
|
const wrapper = join(home,'verify-gpg');
|
||||||
|
await writeFile(wrapper, '#!/bin/sh\nexec gpg --no-options --homedir "$GNUPGHOME" --no-default-keyring --keyring "$GNUPGHOME/public.gpg" --batch --no-tty --no-autostart --no-auto-key-retrieve --auto-key-locate clear "$@"\n', {mode:0o700});
|
||||||
|
const result = run('git', ['-c','core.fsmonitor=false','-c','gpg.format=openpgp','-c',`gpg.program=${wrapper}`,'-c',`gpg.openpgp.program=${wrapper}`,'verify-commit','--raw',revision], {cwd:directory,env:gpgEnv});
|
||||||
|
return {required:true,revision,...verifySignatureStatus(result.stderr,allowed)};
|
||||||
|
} finally {
|
||||||
|
await rm(home,{recursive:true,force:true});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
try {
|
||||||
|
if (process.argv.length !== 2) throw Error('Usage: node scripts/verify-commit-signature.mjs');
|
||||||
|
const result = await verifyCommitSignature();
|
||||||
|
console.log(result.required ? `Verified commit ${result.revision} with pinned primary ${result.primaryFingerprint}.` : `GPG commit gate disabled; checked exact commit ${result.revision}.`);
|
||||||
|
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import {readFile} from 'node:fs/promises';
|
||||||
|
import {resolve} from 'node:path';
|
||||||
|
import {fileURLToPath} from 'node:url';
|
||||||
|
import {createHash} from 'node:crypto';
|
||||||
|
import {parsePublicKeys} from '../cf-worker/src/key-source.js';
|
||||||
|
import {parseSignedDocument, SIGNATURE_SEPARATOR, SIGNED_RELEASE_LIMITS, withoutKeyComments, validateSignature} from '../cf-worker/src/signed-release.js';
|
||||||
|
import {checkSigningCertificate} from './signature-tools.mjs';
|
||||||
|
const hash = text => createHash('sha256').update(text).digest('hex');
|
||||||
|
const root = new URL('../', import.meta.url);
|
||||||
|
|
||||||
|
async function remoteText(url, maxBytes, {allowMissing = false, fetchImpl = fetch} = {}) {
|
||||||
|
const signal = AbortSignal.timeout(20000);
|
||||||
|
const response = await fetchImpl(url, {cache: 'no-store', redirect: 'error', signal});
|
||||||
|
if (allowMissing && response.status === 404) { await response.body?.cancel(); return null; }
|
||||||
|
if (response.status !== 200) { await response.body?.cancel(); throw Error(`Endpoint returned HTTP ${response.status}.`); }
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) return '';
|
||||||
|
let bytes = new Uint8Array(Math.min(16384,maxBytes)), size = 0;
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const {value, done} = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
size += value.byteLength;
|
||||||
|
if (size > maxBytes) throw Error('Remote response exceeds its size limit.');
|
||||||
|
if (size > bytes.length) { const grown = new Uint8Array(Math.min(maxBytes, Math.max(size, bytes.length * 2))); grown.set(bytes); bytes = grown; }
|
||||||
|
bytes.set(value,size-value.byteLength);
|
||||||
|
}
|
||||||
|
return new TextDecoder('utf-8', {fatal: true}).decode(bytes.subarray(0,size));
|
||||||
|
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyRemote(manifest, fetchImpl = fetch) {
|
||||||
|
const read = (path, maxBytes, allowMissing = false) => remoteText(manifest.baseUrl + path, maxBytes, {allowMissing, fetchImpl});
|
||||||
|
const metadata = JSON.parse(await read('/release.json', 64 * 1024));
|
||||||
|
if (metadata.revision !== manifest.revision || metadata.testFixture) throw Error('Remote release does not match this build.');
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(metadata.sha256)) throw Error('Remote key digest is invalid.');
|
||||||
|
const dynamic = manifest.needsSource || manifest.needsFallback || manifest.keySource?.type === 'gist';
|
||||||
|
if (dynamic) {
|
||||||
|
const source = metadata.source;
|
||||||
|
const acceptedFallback = source?.type === 'fallback' && manifest.fallbackSources.includes(source.url);
|
||||||
|
const acceptedGist = manifest.keySource?.type === 'gist' && source?.type === 'gist' && source.id === manifest.keySource.id && source.ownerId === manifest.keySource.ownerId && /^[0-9a-f]{40}$/.test(source.gistRevision);
|
||||||
|
if (!acceptedFallback && !acceptedGist) throw Error('Unexpected effective dynamic source.');
|
||||||
|
} else if (metadata.sha256 !== manifest.sha256) throw Error('Remote repository keys differ from this build.');
|
||||||
|
const document = await read('/ssh.keys', SIGNED_RELEASE_LIMITS.documentBytes);
|
||||||
|
let count, payloadHash, cryptoNote = "";
|
||||||
|
if (document.startsWith('# conf-sshd-signed-v1\n')) {
|
||||||
|
const parsed = await parseSignedDocument(document, {listId: manifest.signedRelease?.listId, checkFreshness: false});
|
||||||
|
count = parsed.keys.length;
|
||||||
|
payloadHash = hash(parsed.keys.map(key => key.text + '\n').join(''));
|
||||||
|
if (!dynamic && parsed.metadata.documentSha256 !== manifest.signedMetadata?.documentSha256) throw Error('Remote signed document differs from this build.');
|
||||||
|
// Each detached signature must validate THIS exact document; an update race fails safely and can be retried.
|
||||||
|
const signature = await read('/ssh.keys.sig', SIGNED_RELEASE_LIMITS.signatureBytes, true);
|
||||||
|
if (manifest.signedRelease?.publicKeyFile) {
|
||||||
|
if (signature === null) throw Error('Remote signature is missing.');
|
||||||
|
const certificate = await read('/public-key.gpg', SIGNED_RELEASE_LIMITS.publicKeyBytes);
|
||||||
|
await checkSigningCertificate(certificate, manifest.signedRelease.fingerprint, document + SIGNATURE_SEPARATOR + signature);
|
||||||
|
} else {
|
||||||
|
if ((manifest.signatureAvailable || manifest.clientDefaults?.gpgVerification === 'on') && signature === null) throw Error('Expected remote signature is missing.');
|
||||||
|
if (signature !== null) validateSignature(signature);
|
||||||
|
cryptoNote = ' OpenPGP not checked: no build verification certificate configured.';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const match = /^# conf-sshd-v1\n# revision ([0-9a-f]{40})\n# key-count (0|[1-9][0-9]*)\n# allow-empty (yes|no)\n# sha256 ([0-9a-f]{64})\n# end-metadata\n([\s\S]*)$/.exec(document);
|
||||||
|
if (!match || match[1] !== manifest.revision) throw Error('Remote key manifest invalid.');
|
||||||
|
const [, , countText, empty, digest, payload] = match;
|
||||||
|
if (hash(payload) !== digest) throw Error('Remote key body does not match its response digest.');
|
||||||
|
const keys = await parsePublicKeys(payload);
|
||||||
|
count = keys.length; payloadHash = digest;
|
||||||
|
if (keys.length !== Number(countText) || (!keys.length && (!manifest.allowEmpty || empty !== 'yes')) || withoutKeyComments(keys).map(key => key.text + '\n').join('') !== payload) throw Error('Remote key count/empty/canonical policy invalid.');
|
||||||
|
if (manifest.clientDefaults?.gpgVerification === 'on') throw Error('Verification-enabled clients require a signed-format document.');
|
||||||
|
}
|
||||||
|
if (!dynamic && payloadHash !== manifest.sha256) throw Error('Remote repository payload differs from this build.');
|
||||||
|
// Dynamic sources may change between requests; build revision is independent of key sequence/digest.
|
||||||
|
return `Remote code release checked: ${metadata.revision}; key payload ${payloadHash} (${count} keys).${cryptoNote}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
const manifest = JSON.parse(await readFile(new URL('cf-worker/generated/manifest.json', root), 'utf8'));
|
||||||
|
if (manifest.testFixture) throw Error('Refusing to deploy a test-fixture build. Run the production build with real server public keys.');
|
||||||
|
if (!process.argv.includes('--remote')) console.log(`Production release checked: ${manifest.revision}`);
|
||||||
|
else console.log(await verifyRemote(manifest));
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
(() => {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const themeKey = "quickly-conf-sshd-theme";
|
||||||
|
const root = document.documentElement;
|
||||||
|
const themePicker = document.getElementById("theme-picker");
|
||||||
|
const status = document.getElementById("copy-status");
|
||||||
|
const dialog = document.getElementById("copy-fallback");
|
||||||
|
const fallbackText = document.getElementById("fallback-text");
|
||||||
|
const closeFallback = document.getElementById("close-copy-fallback");
|
||||||
|
const validThemes = new Set(["system", "light", "dark"]);
|
||||||
|
const resetTimers = new WeakMap();
|
||||||
|
let fallbackTrigger = null;
|
||||||
|
let statusTimer;
|
||||||
|
|
||||||
|
function applyTheme(theme) {
|
||||||
|
const choice = validThemes.has(theme) ? theme : "system";
|
||||||
|
if (choice === "system") root.removeAttribute("data-theme");
|
||||||
|
else root.setAttribute("data-theme", choice);
|
||||||
|
if (themePicker) themePicker.value = choice;
|
||||||
|
return choice;
|
||||||
|
}
|
||||||
|
|
||||||
|
let savedTheme = "system";
|
||||||
|
try {
|
||||||
|
savedTheme = localStorage.getItem(themeKey) || "system";
|
||||||
|
} catch {
|
||||||
|
// Storage may be unavailable in private or restricted browsing contexts.
|
||||||
|
}
|
||||||
|
applyTheme(savedTheme);
|
||||||
|
themePicker?.addEventListener("change", () => {
|
||||||
|
const choice = applyTheme(themePicker.value);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(themeKey, choice);
|
||||||
|
} catch {
|
||||||
|
// The selected theme still applies for this visit.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function announce(message) {
|
||||||
|
if (!status) return;
|
||||||
|
clearTimeout(statusTimer);
|
||||||
|
status.textContent = "";
|
||||||
|
statusTimer = setTimeout(() => { status.textContent = message; }, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showManualCopy(text, trigger) {
|
||||||
|
announce("自动复制未成功。请在手动复制窗口中复制已选中的完整内容。");
|
||||||
|
if (!dialog || !fallbackText) {
|
||||||
|
announce("自动复制未成功。请手动选择页面中的完整内容并复制。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fallbackTrigger = trigger;
|
||||||
|
fallbackText.value = text;
|
||||||
|
if (!dialog.open) {
|
||||||
|
if (typeof dialog.showModal === "function") dialog.showModal();
|
||||||
|
else dialog.setAttribute("open", "");
|
||||||
|
}
|
||||||
|
fallbackText.focus();
|
||||||
|
fallbackText.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeFallback?.addEventListener("click", () => {
|
||||||
|
if (typeof dialog.close === "function") dialog.close();
|
||||||
|
else {
|
||||||
|
dialog.removeAttribute("open");
|
||||||
|
fallbackTrigger?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dialog?.addEventListener("close", () => { fallbackTrigger?.focus(); });
|
||||||
|
|
||||||
|
async function copy(text, button) {
|
||||||
|
if (button.disabled || typeof text !== "string" || text.length === 0) return;
|
||||||
|
const label = button.dataset.copyLabel || button.getAttribute("aria-label") || "复制";
|
||||||
|
button.dataset.copyLabel = label;
|
||||||
|
clearTimeout(resetTimers.get(button));
|
||||||
|
delete button.dataset.state;
|
||||||
|
button.setAttribute("aria-label", label);
|
||||||
|
button.disabled = true;
|
||||||
|
button.setAttribute("aria-busy", "true");
|
||||||
|
try {
|
||||||
|
if (!navigator.clipboard || typeof navigator.clipboard.writeText !== "function") {
|
||||||
|
throw new Error("Clipboard unavailable");
|
||||||
|
}
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
button.dataset.state = "success";
|
||||||
|
button.setAttribute("aria-label", `${label},已复制`);
|
||||||
|
button.setAttribute("title", "已复制");
|
||||||
|
announce("已复制完整内容。");
|
||||||
|
resetTimers.set(button, setTimeout(() => {
|
||||||
|
delete button.dataset.state;
|
||||||
|
button.setAttribute("aria-label", label);
|
||||||
|
button.setAttribute("title", label);
|
||||||
|
}, 2000));
|
||||||
|
} catch {
|
||||||
|
button.setAttribute("title", "自动复制未成功,点击重试");
|
||||||
|
showManualCopy(text, button);
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
button.removeAttribute("aria-busy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll(".code-block pre").forEach((pre) => {
|
||||||
|
pre.setAttribute("tabindex", "0");
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-copy-target]").forEach((button) => {
|
||||||
|
const target = document.getElementById(button.dataset.copyTarget);
|
||||||
|
if (!target) {
|
||||||
|
button.disabled = true;
|
||||||
|
button.setAttribute("title", "复制内容不可用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
button.addEventListener("click", () => copy(target.textContent, button));
|
||||||
|
});
|
||||||
|
|
||||||
|
root.classList.add("enhanced");
|
||||||
|
})();
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--page: #f7f9f8;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-soft: #f0f4f2;
|
||||||
|
--code: #f6f8f7;
|
||||||
|
--text: #1c2923;
|
||||||
|
--muted: #59685f;
|
||||||
|
--subtle: #66766c;
|
||||||
|
--line: #dce5df;
|
||||||
|
--accent: #216f4f;
|
||||||
|
--accent-soft: #e8f4ec;
|
||||||
|
--accent-line: #c5dfce;
|
||||||
|
--shadow: 0 5px 22px rgb(24 48 34 / 3%);
|
||||||
|
--focus: #237e59;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--page: #101713;
|
||||||
|
--surface: #17201b;
|
||||||
|
--surface-soft: #1e2a22;
|
||||||
|
--code: #111b15;
|
||||||
|
--text: #e4ede7;
|
||||||
|
--muted: #afbeb3;
|
||||||
|
--subtle: #a1b4a7;
|
||||||
|
--line: #2d3c32;
|
||||||
|
--accent: #91d8ac;
|
||||||
|
--accent-soft: #203b2b;
|
||||||
|
--accent-line: #345941;
|
||||||
|
--shadow: 0 5px 22px rgb(0 0 0 / 9%);
|
||||||
|
--focus: #8ce0ad;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
color-scheme: dark;
|
||||||
|
--page: #101713;
|
||||||
|
--surface: #17201b;
|
||||||
|
--surface-soft: #1e2a22;
|
||||||
|
--code: #111b15;
|
||||||
|
--text: #e4ede7;
|
||||||
|
--muted: #afbeb3;
|
||||||
|
--subtle: #a1b4a7;
|
||||||
|
--line: #2d3c32;
|
||||||
|
--accent: #91d8ac;
|
||||||
|
--accent-soft: #203b2b;
|
||||||
|
--accent-line: #345941;
|
||||||
|
--shadow: 0 5px 22px rgb(0 0 0 / 9%);
|
||||||
|
--focus: #8ce0ad;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { scroll-padding-top: 28px; }
|
||||||
|
body { margin: 0; background: var(--page); color: var(--text); font-size: 14px; line-height: 1.65; }
|
||||||
|
button, select, textarea { font: inherit; }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
a:hover { color: var(--accent); }
|
||||||
|
button, select { color: inherit; }
|
||||||
|
button, select, summary { cursor: pointer; }
|
||||||
|
:focus-visible { outline: 3px solid var(--focus); outline-offset: 4px; }
|
||||||
|
[hidden], [data-enhance], .copy-button { display: none !important; }
|
||||||
|
.enhanced [data-enhance] { display: inline-flex !important; }
|
||||||
|
.enhanced .copy-button { display: inline-flex !important; }
|
||||||
|
.page-shell { width: min(1120px, calc(100% - 96px)); margin-inline: auto; }
|
||||||
|
.site-header { display: flex; justify-content: space-between; align-items: center; gap: 24px; min-height: 104px; border-bottom: 1px solid var(--line); }
|
||||||
|
.brand { display: flex; align-items: center; gap: 13px; min-width: 0; }
|
||||||
|
.brand-mark { display: grid; place-items: center; flex: 0 0 42px; height: 42px; border: 1px solid var(--accent-line); border-radius: 12px; background: var(--accent-soft); color: var(--accent); font: 700 21px ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: -2px; padding-right: 2px; }
|
||||||
|
.brand strong { display: block; font-size: 14px; font-weight: 650; line-height: 1.4; }
|
||||||
|
.brand-caption { color: var(--subtle); font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||||
|
.header-actions { display: flex; align-items: center; gap: 22px; flex-shrink: 0; }
|
||||||
|
.repository-link { font-size: 13px; font-weight: 600; white-space: nowrap; }
|
||||||
|
.repository-link > span { margin-left: 3px; }
|
||||||
|
.theme-control { align-items: center; gap: 8px; color: var(--muted); font-size: 12px; }
|
||||||
|
.theme-control select { min-height: 36px; max-width: 120px; padding: 4px 26px 4px 10px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface); font-size: 12px; }
|
||||||
|
.hero { display: grid; grid-template-columns: 1fr 350px; align-items: center; gap: 72px; padding: 63px 0 65px; }
|
||||||
|
.eyebrow, .section-index { font: 11px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; font-weight: 550; letter-spacing: 1.5px; color: var(--accent); }
|
||||||
|
.eyebrow { display: flex; align-items: center; gap: 9px; margin: 0 0 18px; }
|
||||||
|
.status-dot { width: 6px; height: 6px; border-radius: 100%; background: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); }
|
||||||
|
h1 { margin: 0; font-size: clamp(30px, 3.4vw, 42px); font-weight: 650; letter-spacing: -1.1px; line-height: 1.45; }
|
||||||
|
.hero-description { margin: 18px 0 21px; color: var(--muted); line-height: 1.85; }
|
||||||
|
.hero-links { display: flex; gap: 25px; color: var(--accent); font-size: 13px; font-weight: 600; }
|
||||||
|
.hero-links a { display: flex; align-items: center; gap: 11px; }
|
||||||
|
.release-card { background: var(--surface); border: 1px solid var(--line); border-radius: 16px; padding: 22px 24px 18px; box-shadow: var(--shadow); min-width: 0; }
|
||||||
|
.release-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||||
|
.release-heading h2 { font-size: 14px; margin: 0; font-weight: 650; }
|
||||||
|
.release-badge { color: var(--accent); background: var(--accent-soft); border: 1px solid var(--accent-line); padding: 2px 8px; border-radius: 5px; font: 10px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||||
|
.release-details { margin: 20px 0 14px; }
|
||||||
|
.release-details > div { display: grid; grid-template-columns: 70px minmax(0, 1fr); gap: 12px; margin-top: 12px; }
|
||||||
|
.release-details dt { font-size: 12px; color: var(--subtle); }
|
||||||
|
.release-details dd { margin: 0; font-size: 12px; min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.release-revision { font-size: 11px; }
|
||||||
|
.source-label { overflow-wrap: anywhere; }
|
||||||
|
.source-status { color: var(--accent); font-weight: 600; }
|
||||||
|
.release-details time { font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||||
|
.key-count { font-size: 20px; line-height: 1; font-weight: 600; }
|
||||||
|
.count-unit { color: var(--subtle); }
|
||||||
|
.release-note { margin: 17px 0 0; padding-top: 14px; border-top: 1px solid var(--line); color: var(--subtle); font-size: 11px; line-height: 1.7; }
|
||||||
|
.content-section { border-top: 1px solid var(--line); padding-top: 32px; margin-bottom: 40px; scroll-margin-top: 24px; }
|
||||||
|
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 20px; }
|
||||||
|
.section-index { color: var(--subtle); font-size: 10px; margin: 0 0 8px; }
|
||||||
|
.section-heading h2 { font-size: 23px; font-weight: 650; letter-spacing: -0.45px; line-height: 1.4; margin: 0; }
|
||||||
|
.section-heading p:not(.section-index) { color: var(--muted); font-size: 12px; margin: 7px 0 0; max-width: 690px; }
|
||||||
|
.section-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 18px; flex-shrink: 0; padding-bottom: 2px; }
|
||||||
|
.text-link { white-space: nowrap; color: var(--muted); font-size: 12px; }
|
||||||
|
.text-link span { margin-left: 4px; }
|
||||||
|
.keys-notice { margin: 0 0 15px; padding: 12px 15px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-soft); color: var(--muted); font-size: 12px; }
|
||||||
|
.keys-notice:empty { display: none; }
|
||||||
|
.command-list, .path-list { display: grid; gap: 14px; min-width: 0; }
|
||||||
|
.command-list, .path-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.command-card { min-width: 0; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); overflow: hidden; }
|
||||||
|
.card-heading { display: flex; align-items: flex-start; gap: 12px; justify-content: space-between; min-width: 0; }
|
||||||
|
.card-heading h3 { min-width: 0; overflow-wrap: anywhere; font-size: 13px; font-weight: 600; margin: 0; line-height: 1.65; }
|
||||||
|
.code-block { position: relative; min-width: 0; border: 1px solid var(--line); border-radius: 7px; background: var(--code); }
|
||||||
|
pre { overflow-x: auto; margin: 0; padding: 14px 62px 14px 14px; white-space: pre; scrollbar-width: thin; scrollbar-color: var(--line) transparent; }
|
||||||
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; font-size: 11px; line-height: 1.8; }
|
||||||
|
.copy-button { --icon-center: 50%; align-items: center; justify-content: center; position: relative; width: 36px; height: 36px; min-width: 36px; padding: 0; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--muted); }
|
||||||
|
.code-block > .copy-button { position: absolute; top: 7px; right: 7px; }
|
||||||
|
.copy-button::before, .copy-button::after { content: ""; display: block; position: absolute; width: 11px; height: 11px; border: 1.5px solid currentColor; border-radius: 2px; }
|
||||||
|
.copy-button::before { left: calc(var(--icon-center) - 8px); top: 9px; }
|
||||||
|
.copy-button::after { left: calc(var(--icon-center) - 4px); top: 13px; background: inherit; }
|
||||||
|
.copy-button:hover { color: var(--accent); border-color: var(--accent-line); background: var(--accent-soft); }
|
||||||
|
.copy-button:disabled { cursor: progress; }
|
||||||
|
.copy-button[data-state="success"] { color: var(--accent); background: var(--accent-soft); border-color: var(--accent-line); }
|
||||||
|
.copy-button[data-state="success"]::before { width: 7px; height: 13px; left: calc(var(--icon-center) - 4px); top: 8px; border: solid currentColor; border-width: 0 2px 2px 0; border-radius: 0; transform: rotate(45deg); }
|
||||||
|
.copy-button[data-state="success"]::after { display: none; }
|
||||||
|
.public-notice { display: flex; align-items: flex-start; gap: 9px; margin-top: 15px; color: var(--subtle); font-size: 11px; }
|
||||||
|
.public-notice p { margin: 0; }
|
||||||
|
.notice-icon { display: grid; place-items: center; flex: 0 0 14px; height: 14px; margin-top: 2px; border: 1px solid var(--subtle); border-radius: 50%; font: 10px/1 serif; }
|
||||||
|
.command-card { display: flex; flex-direction: column; padding: 17px; }
|
||||||
|
.command-description { color: var(--muted); font-size: 12px; line-height: 1.75; margin: 6px 0 13px; }
|
||||||
|
.command-card .code-block { margin-top: auto; }
|
||||||
|
.command-card code { font-size: 10.5px; }
|
||||||
|
.advanced-commands { grid-column: 1 / -1; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); }
|
||||||
|
.advanced-commands summary { padding: 15px 18px; color: var(--muted); font-size: 12px; font-weight: 600; }
|
||||||
|
.advanced-commands summary::marker { color: var(--subtle); }
|
||||||
|
.advanced-commands[open] summary { border-bottom: 1px solid var(--line); }
|
||||||
|
.advanced-commands > .command-list { padding: 15px; }
|
||||||
|
.trust-steps { margin: 0; padding-left: 22px; color: var(--muted); font-size: 12px; }
|
||||||
|
.trust-steps li { padding-left: 4px; margin-block: 10px; }
|
||||||
|
.trust-steps a { color: var(--accent); }
|
||||||
|
.command-note { font-size: 11px; color: var(--subtle); margin: 14px 0 0; }
|
||||||
|
.empty-state { padding: 26px; border: 1px dashed var(--line); border-radius: 10px; color: var(--muted); text-align: center; }
|
||||||
|
.preview-notice { margin: 24px 0 0; padding: 12px 16px; border: 1px solid var(--accent-line); border-radius: 8px; background: var(--accent-soft); color: var(--accent); font-size: 12px; }
|
||||||
|
.noscript-notice { padding: 16px; border: 1px solid var(--line); border-radius: 8px; color: var(--muted); font-size: 12px; }
|
||||||
|
.site-footer { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 24px 0 32px; border-top: 1px solid var(--line); color: var(--subtle); font-size: 11px; }
|
||||||
|
.site-footer > div { display: flex; gap: 22px; }
|
||||||
|
.site-footer > div a { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||||
|
.sr-only { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0); white-space: nowrap !important; border: 0 !important; }
|
||||||
|
.skip-link { position: fixed; z-index: 20; top: -100px; left: 16px; padding: 9px 16px; background: var(--surface); border: 1px solid var(--focus); border-radius: 6px; }
|
||||||
|
.skip-link:focus { top: 12px; }
|
||||||
|
dialog { width: min(640px, calc(100% - 32px)); max-height: calc(100% - 48px); padding: 25px; background: var(--surface); color: var(--text); border: 1px solid var(--line); border-radius: 14px; box-shadow: 0 20px 90px rgb(0 0 0 / 20%); }
|
||||||
|
dialog::backdrop { background: rgb(10 22 15 / 52%); }
|
||||||
|
.fallback-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
|
||||||
|
.fallback-heading h2 { margin: 0; font-size: 18px; }
|
||||||
|
.dialog-close { width: 36px; height: 36px; font-size: 24px; line-height: 1; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-soft); }
|
||||||
|
#fallback-description { color: var(--muted); font-size: 13px; margin: 14px 0; }
|
||||||
|
#fallback-text { display: block; width: 100%; min-height: 150px; resize: vertical; padding: 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--code); color: var(--text); font: 12px/1.8 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||||
|
.fallback-note { color: var(--subtle); font-size: 11px; margin: 12px 0 0; }
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.code-block > .copy-button { opacity: 0; transition: opacity 120ms ease; }
|
||||||
|
.code-block:hover > .copy-button, .code-block:focus-within > .copy-button, .code-block > .copy-button[data-state="success"] { opacity: 1; }
|
||||||
|
}
|
||||||
|
/* Keep copies discoverable on touch/hybrid devices and narrow layouts, even
|
||||||
|
when the browser reports a fine pointer or changes its emulated media. */
|
||||||
|
@media (hover: none), (pointer: coarse), (any-pointer: coarse), (max-width: 680px) {
|
||||||
|
.code-block > .copy-button { opacity: 1; transition: none; }
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.page-shell { width: calc(100% - 56px); }
|
||||||
|
.hero { grid-template-columns: 1fr 305px; gap: 36px; padding-block: 45px; }
|
||||||
|
.release-card { padding: 19px; }
|
||||||
|
.brand strong { max-width: 230px; font-size: 13px; }
|
||||||
|
.header-actions { gap: 16px; }
|
||||||
|
.theme-control label { display: none; }
|
||||||
|
h1 { font-size: 31px; }
|
||||||
|
.wide-only { display: none; }
|
||||||
|
}
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.page-shell { width: calc(100% - 36px); }
|
||||||
|
.site-header { min-height: 108px; flex-wrap: wrap; align-content: center; gap: 12px; padding-block: 17px; }
|
||||||
|
.brand { gap: 10px; }
|
||||||
|
.brand strong { max-width: none; font-size: 12px; }
|
||||||
|
.brand-mark { height: 35px; flex-basis: 35px; border-radius: 9px; font-size: 18px; }
|
||||||
|
.brand-caption { font-size: 10px; }
|
||||||
|
.header-actions { width: 100%; justify-content: space-between; }
|
||||||
|
.theme-control label { display: inline; }
|
||||||
|
.theme-control select { min-height: 34px; }
|
||||||
|
.repository-link { font-size: 12px; }
|
||||||
|
.hero { grid-template-columns: minmax(0, 1fr); gap: 27px; padding: 35px 0; }
|
||||||
|
h1 { font-size: clamp(28px, 7vw, 36px); letter-spacing: -0.7px; }
|
||||||
|
.hero-description { font-size: 13px; margin: 13px 0 17px; }
|
||||||
|
.eyebrow { font-size: 10px; margin-bottom: 14px; }
|
||||||
|
.hero-links { font-size: 12px; }
|
||||||
|
.release-details { margin: 15px 0 0; }
|
||||||
|
.release-details > div { grid-template-columns: 72px minmax(0, 1fr); margin-top: 10px; }
|
||||||
|
.release-note { margin-top: 15px; padding-top: 12px; }
|
||||||
|
.content-section { padding-top: 25px; margin-bottom: 31px; }
|
||||||
|
.section-heading { align-items: flex-start; flex-wrap: wrap; gap: 15px; margin-bottom: 17px; }
|
||||||
|
.section-heading h2 { font-size: 21px; }
|
||||||
|
.section-actions { width: 100%; justify-content: space-between; }
|
||||||
|
.commands-section > .section-heading > .text-link { margin-top: -3px; }
|
||||||
|
.command-card { padding: 14px; }
|
||||||
|
.command-list, .path-list { grid-template-columns: minmax(0, 1fr); gap: 12px; }
|
||||||
|
.card-heading { gap: 8px; }
|
||||||
|
pre { padding-left: 12px; padding-block: 16px; }
|
||||||
|
.copy-button { width: 40px; height: 40px; }
|
||||||
|
.copy-button::before { top: 11px; }
|
||||||
|
.copy-button::after { top: 15px; }
|
||||||
|
.copy-button[data-state="success"]::before { top: 10px; }
|
||||||
|
.public-notice { font-size: 11px; }
|
||||||
|
.site-footer { flex-direction: column; align-items: flex-start; gap: 12px; padding: 22px 0 26px; }
|
||||||
|
dialog { padding: 20px; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; scroll-behavior: auto !important; } }
|
||||||
|
@media print {
|
||||||
|
.header-actions, .hero-links, .section-actions, .copy-button, .enhanced .copy-button, .commands-section .text-link, .site-footer, dialog { display: none !important; }
|
||||||
|
.page-shell { width: 100%; }
|
||||||
|
.hero { padding-block: 24px; }
|
||||||
|
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.command-card { break-inside: avoid; }
|
||||||
|
}
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
<meta name="description" content="查看 SSH 公钥发布状态,复制服务器配置命令。Quickly Configuring SSH Server。">
|
||||||
|
<title>SSH 发布状态与快速部署 · Quickly Configuring SSH Server</title>
|
||||||
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
|
<script src="/assets/app.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#key-status">跳到发布状态</a>
|
||||||
|
<div class="page-shell">
|
||||||
|
<header class="site-header">
|
||||||
|
<a class="brand" href="/" aria-label="Quickly Configuring SSH Server 首页">
|
||||||
|
<span class="brand-mark" aria-hidden="true">>_</span>
|
||||||
|
<span><strong>Quickly Configuring SSH Server</strong><span class="brand-caption">quickly-conf-sshd</span></span>
|
||||||
|
</a>
|
||||||
|
<nav class="header-actions" aria-label="项目与显示设置">
|
||||||
|
<div class="theme-control" data-enhance>
|
||||||
|
<label for="theme-picker">外观</label>
|
||||||
|
<select id="theme-picker" aria-label="选择页面外观">
|
||||||
|
<option value="system">跟随系统</option>
|
||||||
|
<option value="light">浅色</option>
|
||||||
|
<option value="dark">深色</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<a class="repository-link" href="@@REPOSITORY_URL@@" target="_blank" rel="noopener noreferrer">GitHub <span aria-hidden="true">↗</span><span class="sr-only">(新窗口打开)</span></a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
@@PREVIEW_NOTICE@@
|
||||||
|
<section class="hero" aria-labelledby="page-title">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<p class="eyebrow"><span class="status-dot" aria-hidden="true"></span> SSH KEY DISTRIBUTION</p>
|
||||||
|
<h1 id="page-title">公钥集中管理,<br>服务器快速就绪。</h1>
|
||||||
|
<p class="hero-description">查看发布状态,选择命令,配置 SSH。<br class="wide-only">查看当前公钥来源与内容版本,按需部署。</p>
|
||||||
|
<div class="hero-links"><a href="#quick-start">Quick Usage <span aria-hidden="true">↓</span></a><a href="#key-status">Keys <span aria-hidden="true">↓</span></a><a href="#paths">Paths <span aria-hidden="true">↓</span></a></div>
|
||||||
|
</div>
|
||||||
|
<aside class="release-card" aria-labelledby="release-title">
|
||||||
|
<div class="release-heading"><h2 id="release-title">版本与公钥来源</h2><span class="release-badge">@@RELEASE_LABEL@@</span></div>
|
||||||
|
<dl class="release-details">
|
||||||
|
<div><dt>代码版本</dt><dd><code class="release-revision">@@REVISION@@</code></dd></div>
|
||||||
|
<div><dt>公钥版本</dt><dd><code class="release-revision">@@CONTENT_REVISION@@</code></dd></div>
|
||||||
|
<div><dt>公钥来源</dt><dd class="source-label">@@SOURCE_LABEL@@</dd></div>
|
||||||
|
<div><dt>来源状态</dt><dd class="source-status">@@SOURCE_STATUS@@</dd></div>
|
||||||
|
<div><dt>发布时间</dt><dd><time datetime="@@PUBLISHED_AT@@">@@PUBLISHED_AT@@</time></dd></div>
|
||||||
|
<div><dt>公钥数量</dt><dd><strong class="key-count">@@KEY_COUNT@@</strong><span class="count-unit"> 个</span></dd></div>
|
||||||
|
</dl>
|
||||||
|
<p class="release-note">此处展示代码发布与当前公钥来源,不代表所有服务器均已同步。</p>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="quick-start" class="content-section commands-section" aria-labelledby="commands-title">
|
||||||
|
<div class="section-heading"><div><p class="section-index">01 / QUICK USAGE</p><h2 id="commands-title">Quick Usage · 快速使用</h2><p>远程快捷命令会立即执行下载脚本。推荐先检查并独立核验安装脚本;高信任安装与仓库配置见 <a class="text-link" href="@@REPOSITORY_URL@@#readme" target="_blank" rel="noopener noreferrer">README</a>。修改 SSH 配置时保留当前会话以验证新连接。</p></div><a class="text-link" href="/script.sh">查看脚本 <span aria-hidden="true">↗</span></a></div>
|
||||||
|
<div class="command-list">@@COMMANDS_HTML@@</div>
|
||||||
|
<p class="command-note">每条命令独立使用,按说明区分远程首次执行与已安装的本地脚本。普通部署按当前权限处理 SSH Server:root 可安装缺失依赖,非 root 不会尝试提权;-o 跳过 SSH Server 配置。软件包安装钩子可能启动服务,脚本本身不自动重载 SSH。日常同步使用固定本地脚本,不更新可执行代码或信任指纹;原 -u 改用明确重新安装。</p>
|
||||||
|
</section>
|
||||||
|
<section id="key-status" class="content-section" aria-labelledby="keys-title">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div><p class="section-index">02 / KEYS</p><h2 id="keys-title">Keys · 公钥状态</h2><p>首页仅展示来源、状态、数量与内容摘要,不展示公钥列表、指纹或注释。</p></div>
|
||||||
|
<div class="section-actions"><a class="text-link" href="/ssh.keys">公钥原始接口 <span aria-hidden="true">↗</span></a></div>
|
||||||
|
</div>
|
||||||
|
<p class="keys-notice" role="note">@@KEYS_NOTICE@@</p>
|
||||||
|
<div class="public-notice"><span class="notice-icon" aria-hidden="true">i</span><p>公钥不是秘密:原始 API 与公开 Git / Gist 仍可能提供公钥内容。私钥应留在离线环境,本页面不会要求上传或输入私钥。</p></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@@SIGNATURE_SECTION_START@@
|
||||||
|
<section id="signature" class="content-section" aria-labelledby="trust-title">
|
||||||
|
<div class="section-heading"><div><p class="section-index">03 / SIGNATURE</p><h2 id="trust-title">Signature · 签名与本地信任</h2><p>签名文件存在,不代表 Worker 已验证签名。@@VERIFICATION_DEFAULT@@</p></div><a class="text-link" href="@@REPOSITORY_URL@@#readme" target="_blank" rel="noopener noreferrer">完整安装说明 <span aria-hidden="true">↗</span></a></div>
|
||||||
|
<dl class="release-details">
|
||||||
|
<div><dt>签名文件</dt><dd class="signature-status">@@SIGNATURE_STATUS@@</dd></div>
|
||||||
|
<div><dt>公开证书</dt><dd class="certificate-status">@@CERTIFICATE_STATUS@@</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div class="section-actions"><a class="text-link" href="/ssh.keys.sig">独立签名接口 <span aria-hidden="true">↗</span></a>@@CERTIFICATE_LINK@@</div>
|
||||||
|
<ol class="trust-steps">
|
||||||
|
<li>推荐从独立可信渠道取得安装脚本预期 SHA-256,下载、检查脚本并比对摘要后再执行。首次安装或明确重装都会信任你执行的脚本;与脚本同源取得的摘要无法建立独立信任。签名证书 pin 仍须独立核验。</li>
|
||||||
|
<li>可使用安装参数 --install --gpg-verification on,以 --gpg-fingerprint 固定独立核验的完整指纹,并指定 --list-id。--gpg-pin-mode 默认 primary,信任主钥及其有效绑定的签名子钥,也可选 subkey 固定签名子钥;安装或更换信任时须在服务器本地终端确认完整指纹,不能跳过确认;远端发布内容不能关闭本地验签。</li>
|
||||||
|
<li>关闭验签时,客户端仅获取 <a class="text-link" href="/ssh.keys">/ssh.keys</a>,不请求签名或证书;启用后还须取得 <a class="text-link" href="/ssh.keys.sig">/ssh.keys.sig</a>,两者均返回 200,客户端使用已验证并保存的本地证书核验公钥文档原始字节,全部通过才接受。证书仅在本地安装流程中发现并固定,日常同步不联网刷新证书;获取或验证失败保留已有公钥。签名私钥始终离线保管。</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
@@SIGNATURE_SECTION_END@@
|
||||||
|
<section id="paths" class="content-section" aria-labelledby="paths-title">
|
||||||
|
<div class="section-heading"><div><p class="section-index">@@PATHS_INDEX@@ / PATHS</p><h2 id="paths-title">Paths · 访问地址</h2><p>保留原版的公钥、脚本与根地址入口;每个地址可以单独复制。</p></div></div>
|
||||||
|
<div class="path-list">@@PATHS_HTML@@</div>
|
||||||
|
</section>
|
||||||
|
<noscript><p class="noscript-notice">JavaScript 已关闭,发布状态、命令与访问地址仍可查看并手动选择复制。启用 JavaScript 后可使用复制按钮与外观设置。</p></noscript>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer"><span>Quickly Configuring SSH Server</span><div><a href="/ssh.keys.sig">ssh.keys.sig</a><a href="/ssh.keys">ssh.keys</a><a href="/script.sh">script.sh</a><a href="@@REPOSITORY_URL@@" target="_blank" rel="noopener noreferrer">源代码<span class="sr-only">(新窗口打开)</span></a></div></footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="copy-status" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||||
|
<dialog id="copy-fallback" aria-labelledby="fallback-title" aria-describedby="fallback-description">
|
||||||
|
<div class="fallback-heading"><h2 id="fallback-title">手动复制完整内容</h2><button id="close-copy-fallback" class="dialog-close" type="button" aria-label="关闭手动复制窗口">×</button></div>
|
||||||
|
<p id="fallback-description">浏览器未允许自动复制。内容已选中,请按 Ctrl+C 或 ⌘C,也可以长按文本进行复制。</p>
|
||||||
|
<label class="sr-only" for="fallback-text">要复制的完整内容</label>
|
||||||
|
<textarea id="fallback-text" readonly spellcheck="false" wrap="off"></textarea>
|
||||||
|
<p class="fallback-note">关闭此窗口后,可继续查看页面。</p>
|
||||||
|
</dialog>
|
||||||
|
<script id="release-data" type="application/json">@@RELEASE_JSON@@</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { createServer } from "node:http";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { commandsFor, pathsFor, renderPage } from "../cf-worker/src/view.js";
|
||||||
|
|
||||||
|
/** Real page rendering and real local assets, with no live Worker or upstream calls. */
|
||||||
|
export async function startBrowserFixture() {
|
||||||
|
const [template, style, script, fixtureKey] = await Promise.all([
|
||||||
|
readFile(new URL("../site/index.html", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../site/assets/style.css", import.meta.url)),
|
||||||
|
readFile(new URL("../site/assets/app.js", import.meta.url)),
|
||||||
|
readFile(new URL("./fixtures/keys.pub", import.meta.url), "utf8"),
|
||||||
|
]);
|
||||||
|
const [type, encoded] = fixtureKey.trim().split(/\s+/);
|
||||||
|
const comment = 'HOMEPAGE-MUST-NOT-LEAK-COMMENT CI test <img src=x onerror="window.__injected=true"> & "quoted" 中文 ' + "long-comment-".repeat(90);
|
||||||
|
const text = `${type} ${encoded} ${comment}`;
|
||||||
|
const key = {
|
||||||
|
type,
|
||||||
|
text,
|
||||||
|
label: comment,
|
||||||
|
fingerprint: "SHA256:" + createHash("sha256").update(Buffer.from(encoded, "base64")).digest("base64").replace(/=+$/, ""),
|
||||||
|
};
|
||||||
|
const config = { baseUrl: "https://keys.example.test", repositoryUrl: "https://github.com/example/quickly-conf-sshd", signedRelease: {listId: "fixture-signing-list"}, clientDefaults: {gpgVerification: "off"} };
|
||||||
|
const base = {
|
||||||
|
schema: 1,
|
||||||
|
revision: "0123456789abcdef0123456789abcdef01234567",
|
||||||
|
publishedAt: "2026-10-12T12:34:56.000Z",
|
||||||
|
keyCount: 1,
|
||||||
|
allowEmpty: false,
|
||||||
|
sha256: createHash("sha256").update(text + "\n").digest("hex"),
|
||||||
|
testFixture: true,
|
||||||
|
...config,
|
||||||
|
commands: commandsFor(config),
|
||||||
|
paths: pathsFor(config),
|
||||||
|
keys: [key],
|
||||||
|
signedMetadata: null,
|
||||||
|
};
|
||||||
|
const snapshots = {
|
||||||
|
repository: { ...base, publicCertificate: "HOMEPAGE-MUST-NOT-LEAK-CERTIFICATE", source: { type: "repository" } },
|
||||||
|
gist: { ...base, keyRevision: "d".repeat(64), signedMetadata: {listId: "fixture-signing-list", sequence: 42, documentSha256: "d".repeat(64)}, keysText: text, signedDocument: text, signature: "-----BEGIN PGP SIGNATURE-----\nComment: HOMEPAGE-MUST-NOT-LEAK-SIGNATURE\n\nAQID\n-----END PGP SIGNATURE-----\n", signedBundle: text, source: {type: "gist", id: "f".repeat(32), gistRevision: "e".repeat(40), url: "https://gist.github.com/example/" + "f".repeat(32) + "/" + "e".repeat(40), extraPrivateComment: comment} },
|
||||||
|
"signature-disabled": { ...base, signedRelease: null, signatureAvailable: true, signedMetadata: {listId: "runtime-metadata-only"}, paths: pathsFor({...config, signedRelease: null}), source: {type: "repository"} },
|
||||||
|
fallback: { ...base, source: { type: "fallback", url: "https://keys.example.test/team.keys?comment=<script>window.__injected=true</script>" } },
|
||||||
|
unavailable: { ...base, keys: [], keyCount: 0, sha256: null, source: { type: "unavailable" } },
|
||||||
|
pending: { ...base, keys: [], keyCount: 0, sha256: null, source: { type: "pending" } },
|
||||||
|
"explicit-empty": { ...base, allowEmpty: true, signedMetadata: {listId: "fixture-signing-list", sequence: 43, revoke: true}, keys: [], keyCount: 0, sha256: createHash("sha256").update("").digest("hex"), source: { type: "explicit-empty" } },
|
||||||
|
};
|
||||||
|
const pages = new Map(Object.entries(snapshots).map(([name, snapshot]) => ["/" + name, renderPage(template, snapshot)]));
|
||||||
|
const requests = [];
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
const pathname = new URL(request.url, "http://127.0.0.1").pathname;
|
||||||
|
requests.push(pathname);
|
||||||
|
response.setHeader("Cache-Control", "no-store");
|
||||||
|
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
|
response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
|
||||||
|
if (pathname === "/assets/style.css" || pathname === "/assets/app.js") {
|
||||||
|
const isStyle = pathname.endsWith(".css");
|
||||||
|
response.setHeader("Content-Type", isStyle ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8");
|
||||||
|
response.end(isStyle ? style : script);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pathname === "/favicon.ico") {
|
||||||
|
response.statusCode = 204;
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const page = pages.get(pathname === "/" ? "/repository" : pathname);
|
||||||
|
if (page) {
|
||||||
|
response.statusCode = pathname === "/unavailable" ? 503 : 200;
|
||||||
|
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||||
|
response.end(page);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.statusCode = 404;
|
||||||
|
response.end("Fixture route not found\n");
|
||||||
|
});
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); });
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
origin: `http://127.0.0.1:${server.address().port}`,
|
||||||
|
snapshots,
|
||||||
|
requests,
|
||||||
|
close: () => new Promise((resolve, reject) => {
|
||||||
|
server.close((error) => error ? reject(error) : resolve());
|
||||||
|
server.closeAllConnections();
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { access, mkdir } from "node:fs/promises";
|
||||||
|
import { constants } from "node:fs";
|
||||||
|
import { delimiter, join } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { startBrowserFixture } from "./browser-fixture.mjs";
|
||||||
|
|
||||||
|
async function browserExecutable() {
|
||||||
|
const candidates = process.env.CHROMIUM_PATH ? [process.env.CHROMIUM_PATH] : [
|
||||||
|
"/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser",
|
||||||
|
...(process.env.PATH || "").split(delimiter).flatMap((directory) => ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"].map((name) => join(directory, name))),
|
||||||
|
];
|
||||||
|
for (const candidate of new Set(candidates)) {
|
||||||
|
try { await access(candidate, constants.X_OK); return candidate; } catch { /* Check the next installed browser. */ }
|
||||||
|
}
|
||||||
|
throw new Error("BROWSER_TESTS=1 requires an installed Chromium/Google Chrome. Set CHROMIUM_PATH to its executable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function capture(page, name) {
|
||||||
|
if (!process.env.BROWSER_ARTIFACTS_DIR) return;
|
||||||
|
await mkdir(process.env.BROWSER_ARTIFACTS_DIR, { recursive: true });
|
||||||
|
await page.screenshot({ path: join(process.env.BROWSER_ARTIFACTS_DIR, name + ".png"), fullPage: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guardedContext(browser, fixture, options = {}) {
|
||||||
|
const context = await browser.newContext(options);
|
||||||
|
const externalRequests = [];
|
||||||
|
await context.route("**/*", (route) => {
|
||||||
|
if (new URL(route.request().url()).origin === fixture.origin) return route.continue();
|
||||||
|
externalRequests.push(route.request().url());
|
||||||
|
return route.abort("blockedbyclient");
|
||||||
|
});
|
||||||
|
return { context, externalRequests };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installClipboardMock(context) {
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
window.__clipboardTest = { values: [], reject: false };
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
configurable: true,
|
||||||
|
value: { writeText: async (text) => {
|
||||||
|
if (window.__clipboardTest.reject) throw new DOMException("Fixture clipboard denied", "NotAllowedError");
|
||||||
|
window.__clipboardTest.values.push(text);
|
||||||
|
} },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function noPageOverflow(page, width) {
|
||||||
|
const metrics = await page.evaluate(() => ({
|
||||||
|
viewport: window.innerWidth,
|
||||||
|
document: document.documentElement.scrollWidth,
|
||||||
|
body: document.body.scrollWidth,
|
||||||
|
}));
|
||||||
|
assert.equal(metrics.viewport, width);
|
||||||
|
assert.ok(metrics.document <= width && metrics.body <= width, JSON.stringify(metrics));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opt in on a CI runner with a real browser. Ordinary npm test requires no browser or downloads.
|
||||||
|
test("real browser homepage integration", { skip: process.env.BROWSER_TESTS !== "1", timeout: 180_000 }, async (t) => {
|
||||||
|
const { chromium } = await import("playwright-core");
|
||||||
|
const executablePath = await browserExecutable();
|
||||||
|
const fixture = await startBrowserFixture();
|
||||||
|
let browser;
|
||||||
|
try {
|
||||||
|
browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
|
||||||
|
|
||||||
|
for (const width of [390, 1440]) {
|
||||||
|
await t.test(`${width}px layout, themes, clipboard, keyboard and fallback`, async () => {
|
||||||
|
const { context, externalRequests } = await guardedContext(browser, fixture, {
|
||||||
|
viewport: { width, height: 900 },
|
||||||
|
colorScheme: "light",
|
||||||
|
hasTouch: width < 600,
|
||||||
|
isMobile: width < 600,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await installClipboardMock(context);
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
await page.goto(fixture.origin + "/repository");
|
||||||
|
await page.waitForSelector("html.enhanced");
|
||||||
|
await noPageOverflow(page, width);
|
||||||
|
assert.deepEqual(await page.locator("main > section.content-section").evaluateAll((sections) => sections.map((section) => section.id)), ["quick-start", "key-status", "signature", "paths"]);
|
||||||
|
const install = fixture.snapshots.repository.commands.find((command) => command.id === "install").command;
|
||||||
|
assert.equal(await page.locator("#command-install").textContent(), install);
|
||||||
|
assert.equal(await page.locator(".key-card, [data-copy-target^='key-'], #copy-all-keys, #copy-all-commands").count(), 0);
|
||||||
|
const contents = await page.content();
|
||||||
|
assert.ok(!contents.includes(fixture.snapshots.repository.keys[0].text));
|
||||||
|
assert.ok(!contents.includes(fixture.snapshots.repository.keys[0].fingerprint));
|
||||||
|
assert.doesNotMatch(contents, /HOMEPAGE-MUST-NOT-LEAK|ssh-ed25519|SHA256:/);
|
||||||
|
assert.equal(await page.evaluate(() => window.__injected), undefined);
|
||||||
|
assert.equal(await page.locator("#command-install").locator("xpath=..").evaluate((pre) => pre.scrollWidth > pre.clientWidth), true);
|
||||||
|
assert.equal(await page.locator("#theme-picker").inputValue(), "system");
|
||||||
|
assert.equal(await page.locator("html").getAttribute("data-theme"), null);
|
||||||
|
const lightBackground = await page.locator("body").evaluate((body) => getComputedStyle(body).backgroundColor);
|
||||||
|
await capture(page, `homepage-${width}-light`);
|
||||||
|
|
||||||
|
await page.selectOption("#theme-picker", "dark");
|
||||||
|
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||||
|
const darkBackground = await page.locator("body").evaluate((body) => getComputedStyle(body).backgroundColor);
|
||||||
|
assert.notEqual(darkBackground, lightBackground);
|
||||||
|
await capture(page, `homepage-${width}-dark`);
|
||||||
|
await page.reload();
|
||||||
|
assert.equal(await page.locator("#theme-picker").inputValue(), "dark");
|
||||||
|
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||||
|
await page.emulateMedia({ colorScheme: "dark" });
|
||||||
|
await page.selectOption("#theme-picker", "light");
|
||||||
|
assert.equal(await page.locator("body").evaluate((body) => getComputedStyle(body).backgroundColor), lightBackground);
|
||||||
|
await page.selectOption("#theme-picker", "system");
|
||||||
|
assert.equal(await page.locator("html").getAttribute("data-theme"), null);
|
||||||
|
assert.equal(await page.locator("body").evaluate((body) => getComputedStyle(body).backgroundColor), darkBackground);
|
||||||
|
await page.emulateMedia({ colorScheme: "light" });
|
||||||
|
assert.equal(await page.locator("body").evaluate((body) => getComputedStyle(body).backgroundColor), lightBackground);
|
||||||
|
assert.equal(await page.evaluate(() => localStorage.getItem("quickly-conf-sshd-theme")), "system");
|
||||||
|
|
||||||
|
const commandButton = page.locator('[data-copy-target="command-install"]');
|
||||||
|
if (width < 600) {
|
||||||
|
assert.equal(await commandButton.evaluate((button) => getComputedStyle(button).opacity), "1", "touch copy buttons stay visible");
|
||||||
|
} else {
|
||||||
|
await page.locator("#page-title").hover();
|
||||||
|
await page.waitForFunction(() => getComputedStyle(document.querySelector('[data-copy-target="command-install"]')).opacity === "0");
|
||||||
|
}
|
||||||
|
await commandButton.focus();
|
||||||
|
await page.waitForFunction(() => getComputedStyle(document.querySelector('[data-copy-target="command-install"]')).opacity === "1");
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await page.waitForFunction(() => document.querySelector('[data-copy-target="command-install"]').dataset.state === "success");
|
||||||
|
assert.equal(await page.evaluate(() => window.__clipboardTest.values[0]), install);
|
||||||
|
assert.match(await commandButton.getAttribute("aria-label"), /已复制/);
|
||||||
|
await page.waitForFunction(() => document.getElementById("copy-status").textContent.includes("已复制"));
|
||||||
|
|
||||||
|
assert.equal(await page.locator("#copy-all-commands").count(), 0);
|
||||||
|
await page.locator("details summary").click();
|
||||||
|
for (const command of fixture.snapshots.repository.commands.filter((entry) => entry.id !== "install")) {
|
||||||
|
const button = page.locator(`[data-copy-target="command-${command.id}"]`);
|
||||||
|
await button.focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await page.waitForFunction((id) => document.querySelector(`[data-copy-target="command-${id}"]`).dataset.state === "success", command.id);
|
||||||
|
assert.equal(await page.evaluate(() => window.__clipboardTest.values.at(-1)), command.command);
|
||||||
|
}
|
||||||
|
for (const path of fixture.snapshots.repository.paths) {
|
||||||
|
assert.equal(await page.locator(`#path-${path.id}`).textContent(), path.url);
|
||||||
|
const button = page.locator(`[data-copy-target="path-${path.id}"]`);
|
||||||
|
await button.focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await page.waitForFunction((id) => document.querySelector(`[data-copy-target="path-${id}"]`).dataset.state === "success", path.id);
|
||||||
|
assert.equal(await page.evaluate(() => window.__clipboardTest.values.at(-1)), path.url);
|
||||||
|
}
|
||||||
|
await noPageOverflow(page, width);
|
||||||
|
|
||||||
|
await page.evaluate(() => { window.__clipboardTest.reject = true; });
|
||||||
|
await commandButton.focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await page.waitForSelector("#copy-fallback[open]");
|
||||||
|
assert.equal(await page.locator("#fallback-text").inputValue(), install);
|
||||||
|
assert.deepEqual(await page.locator("#fallback-text").evaluate((textarea) => ({
|
||||||
|
start: textarea.selectionStart, end: textarea.selectionEnd, focused: document.activeElement === textarea,
|
||||||
|
})), { start: 0, end: install.length, focused: true });
|
||||||
|
assert.equal(await commandButton.getAttribute("data-state"), null, "failed copy must not show success");
|
||||||
|
await capture(page, `homepage-${width}-manual-copy`);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
assert.equal(await page.locator("#copy-fallback").evaluate((dialog) => dialog.open), false);
|
||||||
|
assert.equal(await commandButton.evaluate((button) => document.activeElement === button), true);
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await page.waitForSelector("#copy-fallback[open]");
|
||||||
|
await page.locator("#close-copy-fallback").click();
|
||||||
|
assert.equal(await commandButton.evaluate((button) => document.activeElement === button), true);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
assert.deepEqual(externalRequests, []);
|
||||||
|
} finally { await context.close(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await t.test("source states expose metadata only and never fetch key or certificate APIs", async () => {
|
||||||
|
const { context, externalRequests } = await guardedContext(browser, fixture, { viewport: { width: 390, height: 844 } });
|
||||||
|
try {
|
||||||
|
await installClipboardMock(context);
|
||||||
|
const page = await context.newPage();
|
||||||
|
for (const [state, sourceText] of [["repository", "可用"], ["gist", "可用"], ["fallback", "备用源可用"], ["unavailable", "暂不可用"], ["pending", "待获取"], ["explicit-empty", "明确撤销"]]) {
|
||||||
|
const response = await page.goto(fixture.origin + "/" + state);
|
||||||
|
assert.equal(response.status(), state === "unavailable" ? 503 : 200);
|
||||||
|
await page.waitForSelector("html.enhanced");
|
||||||
|
assert.equal(await page.locator(".source-status").textContent(), sourceText);
|
||||||
|
const snapshot = fixture.snapshots[state];
|
||||||
|
assert.equal(await page.locator(".key-count").textContent(), String(snapshot.keyCount));
|
||||||
|
assert.equal(await page.locator(".key-card, #copy-all-keys, [data-copy-target^='key-']").count(), 0);
|
||||||
|
const embedded = await page.locator("#release-data").evaluate((element) => JSON.parse(element.textContent));
|
||||||
|
assert.equal(embedded.source.type, state);
|
||||||
|
assert.equal(embedded.keys, undefined);
|
||||||
|
assert.equal(embedded.keyRevision, snapshot.keyRevision || snapshot.sha256);
|
||||||
|
assert.equal(embedded.signaturePresent, Boolean(snapshot.signature && snapshot.signedDocument === snapshot.keysText));
|
||||||
|
assert.equal(embedded.certificatePresent, Boolean(snapshot.publicCertificate));
|
||||||
|
assert.equal(await page.locator(".signature-status").textContent(), snapshot.signature ? "签名包已提供(待客户端验证)" : "未提供签名包");
|
||||||
|
assert.equal(await page.locator(".certificate-status").textContent(), snapshot.publicCertificate ? "公开证书已提供(安装时核验)" : "未提供公开证书");
|
||||||
|
assert.equal(await page.locator('a[href="/public-key.gpg"]').count(), snapshot.publicCertificate ? 1 : 0);
|
||||||
|
assert.equal(await page.locator(".release-revision").first().textContent(), snapshot.revision.slice(0, 12));
|
||||||
|
assert.equal(await page.locator(".release-revision").nth(1).textContent(), (snapshot.keyRevision || snapshot.sha256)?.slice(0, 12) || "—");
|
||||||
|
const content = await page.content();
|
||||||
|
assert.doesNotMatch(content, /HOMEPAGE-MUST-NOT-LEAK|ssh-ed25519|SHA256:|\"keys\"\s*:/);
|
||||||
|
if (state === "fallback") {
|
||||||
|
assert.equal(await page.locator(".source-label").textContent(), "备用源:" + snapshot.source.url);
|
||||||
|
assert.equal(await page.locator(".source-label script").count(), 0);
|
||||||
|
}
|
||||||
|
if (state === "gist") assert.equal(await page.locator(".source-label").textContent(), "Gist:" + snapshot.source.url);
|
||||||
|
if (state === "unavailable" || state === "pending") assert.match(await page.locator(".keys-notice").textContent(), /未发布撤销指令.*保留上次成功密钥/);
|
||||||
|
if (state === "explicit-empty") assert.match(await page.locator(".keys-notice").textContent(), /明确撤销全部受管理公钥/);
|
||||||
|
assert.equal(await page.evaluate(() => window.__injected), undefined);
|
||||||
|
await noPageOverflow(page, 390);
|
||||||
|
// Repository appearance is already captured in both viewports above: 12 screenshots in total.
|
||||||
|
if (state !== "repository") await capture(page, "source-" + state);
|
||||||
|
}
|
||||||
|
await page.goto(fixture.origin + "/signature-disabled");
|
||||||
|
assert.deepEqual(await page.locator("main > section.content-section").evaluateAll((sections) => sections.map((section) => section.id)), ["quick-start", "key-status", "paths"]);
|
||||||
|
assert.equal(await page.locator("#signature, #path-signature, #path-certificate").count(), 0, "runtime signature metadata must not enable the Signature section");
|
||||||
|
assert.equal(await page.locator("#path-root-script").textContent(), fixture.snapshots.repository.baseUrl + "/");
|
||||||
|
assert.deepEqual(externalRequests, []);
|
||||||
|
} finally { await context.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test("no-JavaScript page retains metadata, commands, source states and system theme", async () => {
|
||||||
|
const { context, externalRequests } = await guardedContext(browser, fixture, {
|
||||||
|
viewport: { width: 390, height: 844 }, javaScriptEnabled: false, colorScheme: "dark",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(fixture.origin + "/repository");
|
||||||
|
assert.equal(await page.locator("#key-status").isVisible(), true);
|
||||||
|
assert.equal(await page.locator(".key-card, [data-copy-target^='key-'], #copy-all-keys").count(), 0);
|
||||||
|
assert.equal(await page.locator("#command-install").isVisible(), true);
|
||||||
|
assert.equal(await page.locator("#path-root-script").isVisible(), true);
|
||||||
|
assert.equal(await page.locator("#path-homepage").textContent(), fixture.snapshots.repository.baseUrl + "/");
|
||||||
|
assert.equal(await page.locator('[data-copy-target="command-install"]').isVisible(), false);
|
||||||
|
assert.equal(await page.locator("#theme-picker").isVisible(), false);
|
||||||
|
assert.match(await page.locator("noscript").textContent(), /手动选择复制/);
|
||||||
|
await page.locator("details summary").click();
|
||||||
|
assert.equal(await page.locator("#command-uninstall").isVisible(), true);
|
||||||
|
assert.equal(await page.locator(".repository-link").getAttribute("href"), fixture.snapshots.repository.repositoryUrl);
|
||||||
|
await capture(page, "homepage-no-javascript");
|
||||||
|
await page.goto(fixture.origin + "/unavailable");
|
||||||
|
assert.equal(await page.locator(".source-status").textContent(), "暂不可用");
|
||||||
|
assert.match(await page.locator(".keys-notice").textContent(), /保留上次成功密钥/);
|
||||||
|
assert.deepEqual(externalRequests, []);
|
||||||
|
} finally { await context.close(); }
|
||||||
|
});
|
||||||
|
assert.ok(fixture.requests.every((path) => !["/ssh.keys", "/ssh.keys.sig", "/public-key.gpg", "/key-release", "/release.json"].includes(path)), "homepage must not fetch public-key or certificate material");
|
||||||
|
} finally {
|
||||||
|
await browser?.close();
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {readFile,mkdtemp,cp,writeFile,rm,mkdir} from 'node:fs/promises';
|
||||||
|
import {tmpdir} from 'node:os';
|
||||||
|
import {join} from 'node:path';
|
||||||
|
import {renderPage} from '../cf-worker/src/view.js';
|
||||||
|
import {validateConfig,parseKeys,makeKeysText,commandsFor,build,root,digest} from '../scripts/build.mjs';
|
||||||
|
const config = {baseUrl:'https://ssh.example.com',repositoryUrl:'https://github.com/example/keys',defaultCron:'*/15 * * * *',allowEmpty:false};
|
||||||
|
const fixture = await readFile(new URL('./fixtures/keys.pub',import.meta.url),'utf8');
|
||||||
|
const canonicalFixture=fixture.trim().split(/ +/).slice(0,2).join(' ')+'\n';
|
||||||
|
|
||||||
|
test('deployment configuration rejects shell injection and ambiguous origins',()=>{
|
||||||
|
assert.deepEqual(validateConfig(config),config);
|
||||||
|
for(const baseUrl of ['http://x.example','https://[email protected]','https://x.example/','https://x.example/a','https://x.example;touch /tmp/x','https://x.example\n','https://x.example$(id)','https://x.example:443']) assert.throws(()=>validateConfig({...config,baseUrl}));
|
||||||
|
for(const defaultCron of ['* * * * *\nevil','61 * * * *','*/0 * * * *','*/99 * * * *','* * * 13 *','* * * * * echo x']) assert.throws(()=>validateConfig({...config,defaultCron}));
|
||||||
|
assert.throws(()=>validateConfig({...config,allowEmpty:'false'}));
|
||||||
|
assert.throws(()=>validateConfig({...config,sshKeyUrl:'https://github.com/user.keys'}));
|
||||||
|
});
|
||||||
|
test('keys are structurally validated, canonicalized and fingerprinted',async()=>{
|
||||||
|
const keys = await parseKeys('# a comment\r\n'+fixture.replace(/\n/g,'\r\n'));
|
||||||
|
assert.equal(keys.length,1);assert.match(keys[0].fingerprint,/^SHA256:/);
|
||||||
|
assert.equal(keys[0].text+'\n',fixture);
|
||||||
|
assert.equal((await parseKeys(fixture.trimEnd())).length,1);
|
||||||
|
});
|
||||||
|
test('rejects malformed keys, options, duplicate identities, private material and NUL',async()=>{
|
||||||
|
const key = fixture.trim();const [type,blob] = key.split(' ');
|
||||||
|
for(const input of ['ssh-ed25519 not-a-key','ssh-rsa '+blob,`command="evil" ${key}`,`${key}\n${type} ${blob} different-comment`,'-----BEGIN OPENSSH PRIVATE KEY-----',key+'\0',`ssh-dss ${blob}`]) await assert.rejects(parseKeys(input));
|
||||||
|
});
|
||||||
|
test('empty revocation must be explicit; protocol is deterministic',async()=>{
|
||||||
|
assert.throws(()=>makeKeysText([], 'a'.repeat(40),false),/No server public keys/);
|
||||||
|
const empty = makeKeysText([], 'a'.repeat(40),true);
|
||||||
|
assert.match(empty.text,/# key-count 0\n# allow-empty yes\n/);assert.equal(empty.sha256,digest(''));
|
||||||
|
const keys=await parseKeys(fixture);const bundle=makeKeysText(keys,'b'.repeat(40),false);
|
||||||
|
assert.ok(bundle.text.endsWith(canonicalFixture));assert.equal(bundle.sha256,digest(canonicalFixture));
|
||||||
|
assert.throws(()=>makeKeysText(keys,'main',false),/commit SHA/);
|
||||||
|
});
|
||||||
|
test('homepage excludes keys and commands use explicit install and fixed local sync',async()=>{
|
||||||
|
const commands=commandsFor(config);
|
||||||
|
assert.ok(commands.find(x=>x.id==='install').command.includes('--install'));
|
||||||
|
assert.equal(Boolean(commands.find(x=>x.id==='root-on').advanced), false);
|
||||||
|
assert.ok(commands.find(x=>x.id==='root-off').advanced);
|
||||||
|
assert.ok(commandsFor({...config,defaultCron:'0 */6 * * *'})[0].description.includes('0 */6 * * *'));
|
||||||
|
assert.ok(commands.find(x=>x.id==='uninstall').advanced);
|
||||||
|
assert.ok(commands.every(x=>!x.command.includes('github.io')));
|
||||||
|
assert.ok(!commands.some(x=>/ -u$/.test(x.command)));
|
||||||
|
assert.ok(commands.find(x=>x.id==='once').command.includes('$HOME/.conf-sshd/conf-sshd.sh'));
|
||||||
|
});
|
||||||
|
test('build creates consistent assets/release; production empty is blocked',async()=>{
|
||||||
|
const directory=await mkdtemp(join(tmpdir(),'conf-sshd-build-'));
|
||||||
|
try {
|
||||||
|
for(const path of ['site','tests/fixtures','keys']) {await mkdir(join(directory,path),{recursive:true});await cp(join(root,path),join(directory,path),{recursive:true});}
|
||||||
|
await cp(join(root,'conf-sshd.sh'),join(directory,'conf-sshd.sh'));
|
||||||
|
await writeFile(join(directory,'deployment.json'),JSON.stringify(config));
|
||||||
|
const options={directory,revision:'c'.repeat(40),publishedAt:'2026-10-12T00:00:00.000Z'};
|
||||||
|
await assert.rejects(build(options),/No server public keys/);
|
||||||
|
const release=await build({...options,testFixture:true});assert.equal(release.testFixture,true);
|
||||||
|
const html=await readFile(join(directory,'cf-worker/public/index.html'),'utf8');
|
||||||
|
assert.ok(!html.includes('@@'));assert.ok(html.includes('cccccccccccc'));assert.match(html,/\/assets\/app\.[a-f0-9]{12}\.js/);
|
||||||
|
const generated=await readFile(join(directory,'cf-worker/generated/release.js'),'utf8');
|
||||||
|
assert.ok(generated.includes('# conf-sshd-v1'));assert.ok(generated.includes('https://ssh.example.com'));assert.ok(!generated.includes('@@BASE_URL@@'));
|
||||||
|
await writeFile(join(directory,'keys/authorized_keys'),fixture);
|
||||||
|
assert.equal((await build(options)).testFixture,false);
|
||||||
|
} finally {await rm(directory,{recursive:true,force:true});}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fallback configuration allows an empty primary only with explicit bounded HTTPS sources',async()=>{
|
||||||
|
assert.throws(()=>validateConfig({...config,fallbackSources:['http://keys.example.com']}));
|
||||||
|
assert.throws(()=>validateConfig({...config,fallbackSources:['https://user:[email protected]']}));
|
||||||
|
assert.throws(()=>validateConfig({...config,fallbackSources:Array(6).fill('https://keys.example.com')}));
|
||||||
|
const directory=await mkdtemp(join(tmpdir(),'conf-sshd-fallback-build-'));
|
||||||
|
try {
|
||||||
|
for(const path of ['site','tests/fixtures','keys']) {await mkdir(join(directory,path),{recursive:true});await cp(join(root,path),join(directory,path),{recursive:true});}
|
||||||
|
await cp(join(root,'conf-sshd.sh'),join(directory,'conf-sshd.sh'));
|
||||||
|
await writeFile(join(directory,'deployment.json'),JSON.stringify({...config,fallbackSources:['https://keys.example.com/server.keys']}));
|
||||||
|
const release=await build({directory,revision:'d'.repeat(40)});
|
||||||
|
assert.equal(release.needsFallback,true);assert.equal(release.sha256,null);assert.equal(release.keyCount,0);
|
||||||
|
assert.ok((await readFile(join(directory,'cf-worker/public/index.html'),'utf8')).includes('未发布撤销指令'));
|
||||||
|
} finally {await rm(directory,{recursive:true,force:true});}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('template rendering excludes even adversarial key comments from HTML and JSON',async()=>{
|
||||||
|
const tricky=fixture.trim()+" $& $` $' $$ @@REVISION@@ @@KEYS_HTML@@";
|
||||||
|
const keys=await parseKeys(tricky);
|
||||||
|
const template='<div>@@SOURCE_LABEL@@</div><script type="application/json">@@RELEASE_JSON@@</script>';
|
||||||
|
const html=renderPage(template,{revision:'e'.repeat(40),publishedAt:'2026-10-12',keys,commands:[],repositoryUrl:config.repositoryUrl,sha256:'f'.repeat(64)});
|
||||||
|
const json=JSON.parse(html.match(/<script type="application\/json">([\s\S]*?)<\/script>/)[1]);
|
||||||
|
assert.equal(json.keys,undefined);assert.ok(!html.includes(keys[0].text));assert.ok(!html.includes(keys[0].fingerprint));
|
||||||
|
assert.throws(()=>renderPage('@@UNKNOWN@@',{revision:'e'.repeat(40),keys:[]}),/Unresolved/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {spawnSync} from 'node:child_process';
|
||||||
|
import {readFile, mkdtemp, mkdir, rm} from 'node:fs/promises';
|
||||||
|
import {tmpdir} from 'node:os';
|
||||||
|
import {join} from 'node:path';
|
||||||
|
import {verifyCommitSignature, enabledFlag, parseFingerprints, verifySignatureStatus} from '../scripts/verify-commit-signature.mjs';
|
||||||
|
import {notifyDeployment, deploymentPayload} from '../scripts/notify-deployment.mjs';
|
||||||
|
import {openPgpFixture} from './helpers/openpgp-fixture.mjs';
|
||||||
|
|
||||||
|
const primary = 'A'.repeat(40), subkey = 'B'.repeat(40);
|
||||||
|
const validStatus = (fingerprint = primary, signer = fingerprint) => `[GNUPG:] GOODSIG 1234567890ABCDEF Test fixture\n[GNUPG:] VALIDSIG ${signer} 2026-01-01 1767225600 0 4 0 22 8 00 ${fingerprint}\n`;
|
||||||
|
|
||||||
|
test('GPG opt-in is strict and only accepts complete primary fingerprint pins', () => {
|
||||||
|
for (const value of [undefined,'','false']) assert.equal(enabledFlag(value),false);
|
||||||
|
assert.equal(enabledFlag('true'),true);
|
||||||
|
for (const value of ['TRUE','yes','1',' false ','False',null]) assert.throws(() => enabledFlag(value));
|
||||||
|
assert.deepEqual(parseFingerprints(primary.toLowerCase()+',\n'+subkey),new Set([primary,subkey]));
|
||||||
|
for (const value of ['',undefined,'1234ABCD','A'.repeat(64),primary+' invalid']) assert.throws(() => parseFingerprints(value));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('machine status requires a pinned primary and rejects bad, expired and revoked signatures', () => {
|
||||||
|
const pins = new Set([primary]);
|
||||||
|
assert.deepEqual(verifySignatureStatus(validStatus(primary,subkey),pins),{signerFingerprint:subkey,primaryFingerprint:primary});
|
||||||
|
assert.throws(() => verifySignatureStatus(validStatus(subkey),pins),/not pinned/);
|
||||||
|
assert.throws(() => verifySignatureStatus('[GNUPG:] GOODSIG 123 Test fixture\n',pins),/exactly one/);
|
||||||
|
assert.throws(() => verifySignatureStatus(validStatus()+validStatus(),pins),/exactly one/);
|
||||||
|
for (const code of ['BADSIG','ERRSIG','NO_PUBKEY','EXPSIG','EXPKEYSIG','REVKEYSIG','KEYEXPIRED','SIGEXPIRED','KEYREVOKED','FAILURE','ERROR','NODATA','BADARMOR']) {
|
||||||
|
assert.throws(() => verifySignatureStatus(validStatus()+`[GNUPG:] ${code} rejected\n`,pins),/invalid, expired, revoked/);
|
||||||
|
}
|
||||||
|
assert.throws(() => verifySignatureStatus(validStatus().replace('1767225600 0 4','1767225600 1767225601 4'),pins,1767225602),/expired/);
|
||||||
|
assert.throws(() => verifySignatureStatus(validStatus().replace('22 8 00','22 2 00'),pins),/SHA-256/);
|
||||||
|
assert.throws(() => verifySignatureStatus(validStatus().replace(' 4 0 22',' 5 0 22'),pins),/v4/);
|
||||||
|
assert.throws(() => verifySignatureStatus(validStatus().replace(` 00 ${primary}`,' 00'),pins),/primary fingerprint/);
|
||||||
|
});
|
||||||
|
|
||||||
|
function command(bin,args,options = {}) {
|
||||||
|
const result = spawnSync(bin,args,{encoding:'utf8',timeout:30_000,...options});
|
||||||
|
assert.equal(result.error,undefined,`${bin} failed to execute`);
|
||||||
|
assert.equal(result.status,0,`${bin} failed: ${result.stderr}`);
|
||||||
|
return result.stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('GPG gate verifies real ephemeral Git commits in an isolated keyring', {timeout:120_000}, async t => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(),'conf-sshd-signature-test-'));
|
||||||
|
const repo = join(directory,'repo');await mkdir(repo);
|
||||||
|
const env = {...process.env,GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:'/dev/null'};
|
||||||
|
const git = (args,options = {}) => command('git',args,{cwd:repo,env,...options});
|
||||||
|
const commit = (name,fixture) => {
|
||||||
|
const tree = git(['hash-object','-t','tree','-w','--stdin'],{input:''}).trim();
|
||||||
|
const headers = `tree ${tree}\nauthor Security fixture <[email protected]> 1767225600 +0000\ncommitter Security fixture <[email protected]> 1767225600 +0000`;
|
||||||
|
const body = `${headers}\n\n${name}\n`;
|
||||||
|
const signed = fixture ? `${headers}\ngpgsig ${fixture.sign(body).trimEnd().replaceAll('\n','\n ')}\n\n${name}\n` : body;
|
||||||
|
const sha = git(['hash-object','-t','commit','-w','--stdin'],{input:signed}).trim();
|
||||||
|
git(['update-ref','HEAD',sha]);return sha;
|
||||||
|
};
|
||||||
|
const gateEnv = (sha,pin,key) => ({...env,GITHUB_SHA:sha,REQUIRE_GPG_SIGNATURE:'true',GPG_ALLOWED_PRIMARY_FINGERPRINTS:pin,GPG_PUBLIC_KEYS:key});
|
||||||
|
try {
|
||||||
|
git(['init','-q']);
|
||||||
|
const good = openPgpFixture(), other = openPgpFixture(), sub = openPgpFixture({subkeys:2});
|
||||||
|
let sha = commit('unsigned fixture');
|
||||||
|
await t.test('disabled still pins HEAD; enabled unsigned/missing config/incorrect SHA fail',async () => {
|
||||||
|
assert.equal((await verifyCommitSignature({env:{...env,GITHUB_SHA:sha},directory:repo})).required,false);
|
||||||
|
await assert.rejects(verifyCommitSignature({env:{...env,GITHUB_SHA:'0'.repeat(40)},directory:repo}),/does not match/);
|
||||||
|
await assert.rejects(verifyCommitSignature({env:{...env,GITHUB_SHA:sha,REQUIRE_GPG_SIGNATURE:'invalid'},directory:repo}),/exactly true/);
|
||||||
|
await assert.rejects(verifyCommitSignature({env:{...env,GITHUB_SHA:sha,REQUIRE_GPG_SIGNATURE:'true'},directory:repo}),/FINGERPRINTS/);
|
||||||
|
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,good.primaryFingerprint,''),directory:repo}),/public keys/);
|
||||||
|
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,good.primaryFingerprint,good.publicKey),directory:repo}),/Git verification command failed/);
|
||||||
|
});
|
||||||
|
sha = commit('approved signature fixture',good);
|
||||||
|
await t.test('approved primary passes; wrong pin, missing public signer and Git nonzero fail',async () => {
|
||||||
|
assert.equal((await verifyCommitSignature({env:gateEnv(sha,good.primaryFingerprint,good.publicKey),directory:repo})).primaryFingerprint,good.primaryFingerprint);
|
||||||
|
assert.equal((await verifyCommitSignature({env:gateEnv(sha,`${other.primaryFingerprint},${good.primaryFingerprint}`,other.publicKey+good.publicKey),directory:repo})).primaryFingerprint,good.primaryFingerprint);
|
||||||
|
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,other.primaryFingerprint,good.publicKey),directory:repo}),/not pinned/);
|
||||||
|
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,good.primaryFingerprint,other.publicKey),directory:repo}),/Git verification command failed/);
|
||||||
|
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,good.primaryFingerprint,good.publicKey),directory}),/Git verification command failed/);
|
||||||
|
});
|
||||||
|
sha = commit('tampered commit fixture',{sign:data=>good.sign(data.replace('tampered commit fixture','original commit fixture'))});
|
||||||
|
await t.test('a valid signature on different commit bytes is rejected',async () => {
|
||||||
|
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,good.primaryFingerprint,good.publicKey),directory:repo}),/Git verification command failed/);
|
||||||
|
});
|
||||||
|
sha = commit('signing subkey fixture',sub);
|
||||||
|
await t.test('signing subkey is accepted only through the pinned primary fingerprint',async () => {
|
||||||
|
const result = await verifyCommitSignature({env:gateEnv(sha,sub.primaryFingerprint,sub.publicKey),directory:repo});
|
||||||
|
assert.equal(result.primaryFingerprint,sub.primaryFingerprint);assert.equal(result.signerFingerprint,sub.signerFingerprint);assert.notEqual(result.signerFingerprint,sub.primaryFingerprint);
|
||||||
|
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,sub.signerFingerprint,sub.publicKey),directory:repo}),/not pinned/);
|
||||||
|
const secondSha = commit('second signing subkey fixture',{sign:data=>sub.sign(data,1)});
|
||||||
|
const second = await verifyCommitSignature({env:gateEnv(secondSha,sub.primaryFingerprint,sub.publicKey),directory:repo});
|
||||||
|
assert.equal(second.primaryFingerprint,sub.primaryFingerprint);assert.equal(second.signerFingerprint,sub.signerFingerprints[1]);
|
||||||
|
});
|
||||||
|
const revoked = openPgpFixture({revoked:true});sha = commit('revoked fixture',revoked);
|
||||||
|
await t.test('real primary revocation rejects a previously created signature',async () => {
|
||||||
|
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,revoked.primaryFingerprint,revoked.publicKey),directory:repo}),/Git verification command failed|revoked/);
|
||||||
|
});
|
||||||
|
const expired = openPgpFixture({expired:true});sha = commit('expired key fixture',expired);
|
||||||
|
await t.test('expired signing key is rejected using current verifier time',async () => {
|
||||||
|
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,expired.primaryFingerprint,expired.publicKey),directory:repo}),/Git verification command failed|expired/);
|
||||||
|
});
|
||||||
|
sha = commit('forced verifier fixture',good);
|
||||||
|
await t.test('Git configuration cannot substitute another signature format or verifier',async () => {
|
||||||
|
git(['config','gpg.format','ssh']);git(['config','gpg.openpgp.program','/bin/true']);git(['config','gpg.program','/bin/true']);
|
||||||
|
assert.equal((await verifyCommitSignature({env:gateEnv(sha,good.primaryFingerprint,good.publicKey),directory:repo})).primaryFingerprint,good.primaryFingerprint);
|
||||||
|
});
|
||||||
|
} finally {await rm(directory,{recursive:true,force:true});}
|
||||||
|
});
|
||||||
|
|
||||||
|
const notificationEnv = {
|
||||||
|
ENABLE_MAIL_NOTIFICATION:'true',CI:'true',GITHUB_ACTIONS:'true',
|
||||||
|
MAIL_NOTIFICATION_WEBHOOK:'https://mailer.example.invalid/hook?token=DO_NOT_LOG',
|
||||||
|
MAIL_NOTIFICATION_TO:'[email protected]',DEPLOYMENT_STATUS:'success',DEPLOYMENT_PHASE:'complete',
|
||||||
|
GITHUB_SHA:'c'.repeat(40),GITHUB_REPOSITORY:'example/server-keys',GITHUB_RUN_ID:'123',
|
||||||
|
};
|
||||||
|
const deploymentConfig = {
|
||||||
|
baseUrl:'https://ssh.example.invalid',keySource:{type:'gist'},
|
||||||
|
signedRelease:{listId:'server-keys-production'},fallbackSources:['https://backup.example.invalid/server.keys'],
|
||||||
|
};
|
||||||
|
|
||||||
|
test('notification stays network-free unless explicitly enabled in CI with complete configuration',async () => {
|
||||||
|
let calls = 0;const fetchImpl = async () => {calls++;return {ok:true,status:200};};
|
||||||
|
for (const ENABLE_MAIL_NOTIFICATION of [undefined,'','false']) {
|
||||||
|
assert.deepEqual(await notifyDeployment({env:{ENABLE_MAIL_NOTIFICATION},fetchImpl}),{sent:false,reason:'disabled'});
|
||||||
|
}
|
||||||
|
for (const value of ['yes','TRUE','1']) await assert.rejects(notifyDeployment({env:{...notificationEnv,ENABLE_MAIL_NOTIFICATION:value},config:deploymentConfig,fetchImpl}),/exactly true/);
|
||||||
|
await assert.rejects(notifyDeployment({env:{...notificationEnv,CI:'false'},config:deploymentConfig,fetchImpl}),/only in GitHub Actions/);
|
||||||
|
await assert.rejects(notifyDeployment({env:{...notificationEnv,MAIL_NOTIFICATION_WEBHOOK:''},config:deploymentConfig,fetchImpl}),/required/);
|
||||||
|
for (const endpoint of ['http://mailer.example.invalid','https://user:[email protected]','https://mailer.example.invalid/#secret']) {
|
||||||
|
await assert.rejects(notifyDeployment({env:{...notificationEnv,MAIL_NOTIFICATION_WEBHOOK:endpoint},config:deploymentConfig,fetchImpl}),/HTTPS/);
|
||||||
|
}
|
||||||
|
assert.equal(calls,0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notification JSON identifies deployment, configured mode/list/fallbacks and uses a bounded nonredirecting POST',async () => {
|
||||||
|
for (const [status,phase] of [['success','complete'],['failure','deployment'],['failure','remote-verification']]) {
|
||||||
|
const result = await notifyDeployment({env:{...notificationEnv,DEPLOYMENT_STATUS:status,DEPLOYMENT_PHASE:phase},config:deploymentConfig,fetchImpl:async (url,options) => {
|
||||||
|
assert.equal(url,notificationEnv.MAIL_NOTIFICATION_WEBHOOK);assert.equal(options.method,'POST');assert.equal(options.redirect,'error');
|
||||||
|
assert.equal(options.headers['content-type'],'application/json');assert.ok(options.signal instanceof AbortSignal);
|
||||||
|
const payload = JSON.parse(options.body);
|
||||||
|
assert.equal(payload.status,status);assert.equal(payload.phase,phase);assert.equal(payload.revision,notificationEnv.GITHUB_SHA);
|
||||||
|
assert.equal(payload.configuration.mode,'gist');assert.equal(payload.configuration.listId,'server-keys-production');
|
||||||
|
assert.deepEqual(payload.configuration.fallbackSources,deploymentConfig.fallbackSources);assert.match(payload.configurationScope,/not measured server state/);
|
||||||
|
assert.equal(payload.to,'[email protected]');assert.ok(!options.body.includes('DO_NOT_LOG'));
|
||||||
|
return {ok:true,status:202};
|
||||||
|
}});
|
||||||
|
assert.deepEqual(result,{sent:true});
|
||||||
|
}
|
||||||
|
const defaults = deploymentPayload({baseUrl:'https://ssh.example.invalid'}, {...notificationEnv,MAIL_NOTIFICATION_TO:''});
|
||||||
|
assert.equal(defaults.configuration.mode,'git');assert.equal(defaults.configuration.listId,null);assert.equal(defaults.to,null);
|
||||||
|
assert.deepEqual(defaults.configuration.fallbackSources,[]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notification rejects ambiguous metadata and never exposes webhook tokens or retries failures',async () => {
|
||||||
|
for (const patch of [{DEPLOYMENT_STATUS:'unknown'},{DEPLOYMENT_PHASE:'unknown'},{GITHUB_SHA:'main'},{GITHUB_REPOSITORY:'evil\nrepo'},{GITHUB_RUN_ID:'$((1))'},{MAIL_NOTIFICATION_TO:'[email protected]\nBcc:[email protected]'}]) {
|
||||||
|
assert.throws(() => deploymentPayload(deploymentConfig,{...notificationEnv,...patch}));
|
||||||
|
}
|
||||||
|
let calls = 0;
|
||||||
|
await assert.rejects(notifyDeployment({env:notificationEnv,config:deploymentConfig,fetchImpl:async () => {calls++;throw Error(notificationEnv.MAIL_NOTIFICATION_WEBHOOK);}}),error=>!error.message.includes('DO_NOT_LOG') && /request failed/.test(error.message));
|
||||||
|
assert.equal(calls,1);
|
||||||
|
await assert.rejects(notifyDeployment({env:notificationEnv,config:deploymentConfig,fetchImpl:async () => ({ok:false,status:503})}),/HTTP 503/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deployment workflow gates both entry points before repository execution and separates secret scopes',async () => {
|
||||||
|
const workflow = await readFile(new URL('../.github/workflows/deploy.yml',import.meta.url),'utf8');
|
||||||
|
const verify = workflow.split('\n verify:\n')[1].split('\n deploy:\n')[0];
|
||||||
|
const deploy = workflow.split('\n deploy:\n')[1].split('\n notify:\n')[0];
|
||||||
|
const notify = workflow.split('\n notify:\n')[1];
|
||||||
|
assert.match(workflow,/workflow_dispatch:/);assert.match(verify,/refs\/heads\/main/);assert.match(verify,/verify-commit-signature\.mjs/);
|
||||||
|
assert.ok(!verify.includes('${{ secrets.'));assert.ok(!verify.includes('environment: production'));assert.ok(!verify.includes('npm '));
|
||||||
|
assert.match(deploy,/needs: verify/);assert.match(deploy,/environment: production/);
|
||||||
|
assert.equal((workflow.match(/ref: \$\{\{ github.sha \}\}/g)||[]).length,3);
|
||||||
|
assert.match(notify,/needs\.deploy\.outputs\.attempted == 'true'/);assert.ok(!notify.includes('CLOUDFLARE_'));
|
||||||
|
assert.match(notify,/DEPLOYMENT_PHASE:/);assert.match(notify,/MAIL_NOTIFICATION_WEBHOOK/);
|
||||||
|
});
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINdamAGCsQq31Uv+08lkBzoO4XLz2qYjJa8CGmj3B1Ea TEST-ONLY-RFC8032-public-key
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"testOnlyNotice": "Public integration-test identity only. Not production authorized keys or deployment trust. Do not change this fixture for a fork deployment.",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"primaryFingerprint": "7EF5FB1E65C1A6C6D02DFCDFCE57746D7E5EE6EF",
|
||||||
|
"signingSubkeyFingerprint": "B1B89449E5877614EA672F5F6C5AE2A913941E1D",
|
||||||
|
"keyservers": ["hkps://keys.openpgp.org", "hkps://keyserver.ubuntu.com"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Test-only OpenPGP v4 fixture encoder. Private keys are generated in memory,
|
||||||
|
// never saved; production verification uses GnuPG, never this implementation.
|
||||||
|
// This keeps integration tests runnable when gpg-agent sockets are unavailable.
|
||||||
|
import {generateKeyPairSync, createHash, sign} from 'node:crypto';
|
||||||
|
|
||||||
|
const bytes = (...values) => Buffer.concat(values.map(value => Buffer.isBuffer(value) ? value : Buffer.from(value)));
|
||||||
|
const u16 = value => {const out = Buffer.alloc(2);out.writeUInt16BE(value);return out;};
|
||||||
|
const u32 = value => {const out = Buffer.alloc(4);out.writeUInt32BE(value);return out;};
|
||||||
|
const sha = (algorithm,value) => createHash(algorithm).update(value).digest();
|
||||||
|
function mpi(input) {
|
||||||
|
const first = input.findIndex(value => value !== 0), data = first < 0 ? Buffer.alloc(0) : input.subarray(first);
|
||||||
|
return bytes(u16(data.length ? (data.length-1)*8 + 32-Math.clz32(data[0]) : 0),data);
|
||||||
|
}
|
||||||
|
function length(size) {
|
||||||
|
if (size < 192) return Buffer.from([size]);
|
||||||
|
if (size <= 8383) return Buffer.from([((size-192)>>8)+192,(size-192)&255]);
|
||||||
|
return bytes([255],u32(size));
|
||||||
|
}
|
||||||
|
const packet = (type,body) => bytes([0xc0|type],length(body.length),body);
|
||||||
|
const subpacket = (type,body) => bytes(length(body.length+1),[type],body);
|
||||||
|
const keyPrefix = body => bytes([0x99],u16(body.length),body);
|
||||||
|
function armor(type,data) {
|
||||||
|
let crc = 0xb704ce;
|
||||||
|
for (const byte of data) {crc ^= byte<<16;for(let bit=0;bit<8;bit++) {crc <<= 1;if(crc&0x1000000) crc ^= 0x1864cfb;}}
|
||||||
|
const checksum = Buffer.from([(crc>>16)&255,(crc>>8)&255,crc&255]).toString('base64');
|
||||||
|
return `-----BEGIN PGP ${type}-----\n\n${data.toString('base64').match(/.{1,64}/g).join('\n')}\n=${checksum}\n-----END PGP ${type}-----\n`;
|
||||||
|
}
|
||||||
|
function key(created) {
|
||||||
|
const {publicKey,privateKey} = generateKeyPairSync('rsa',{modulusLength:2048});
|
||||||
|
const jwk = publicKey.export({format:'jwk'});
|
||||||
|
const body = bytes([4],u32(created),[1],mpi(Buffer.from(jwk.n,'base64url')),mpi(Buffer.from(jwk.e,'base64url')));
|
||||||
|
return {body,privateKey,fingerprint:sha('sha1',keyPrefix(body)),created};
|
||||||
|
}
|
||||||
|
function signature(signer,type,data,{created = signer.created,extra = []} = {}) {
|
||||||
|
const hashed = bytes(subpacket(2,u32(created)),subpacket(33,bytes([4],signer.fingerprint)),...extra);
|
||||||
|
const head = bytes([4,type,1,8],u16(hashed.length),hashed);
|
||||||
|
const toSign = bytes(data,head,[4,255],u32(head.length));
|
||||||
|
const unhashed = subpacket(16,signer.fingerprint.subarray(-8));
|
||||||
|
return bytes(head,u16(unhashed.length),unhashed,sha('sha256',toSign).subarray(0,2),mpi(sign('RSA-SHA256',toSign,signer.privateKey)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openPgpFixture({subkey = false,subkeys = subkey ? 1 : 0,expired = false,revoked = false} = {}) {
|
||||||
|
if (!Number.isInteger(subkeys) || subkeys < 0 || subkeys > 4) throw Error('Fixture supports zero through four signing subkeys.');
|
||||||
|
const now = Math.floor(Date.now()/1000), created = now - (expired ? 5*86400 : 60);
|
||||||
|
const primary = key(created);
|
||||||
|
const uid = Buffer.from('Ephemeral security fixture <[email protected]>');
|
||||||
|
const certification = signature(primary,0x13,bytes(keyPrefix(primary.body),[0xb4],u32(uid.length),uid),{
|
||||||
|
extra:[subpacket(27,[subkeys ? 1 : 3]),...(expired ? [subpacket(9,u32(86400))] : [])],
|
||||||
|
});
|
||||||
|
const revocation = revoked ? packet(2,signature(primary,0x20,keyPrefix(primary.body),{created:created+10})) : Buffer.alloc(0);
|
||||||
|
let publicPackets = bytes(packet(6,primary.body),revocation,packet(13,uid),packet(2,certification));
|
||||||
|
const signers = [];
|
||||||
|
for (let index = 0; index < subkeys; index++) {
|
||||||
|
const signingKey = key(created);signers.push(signingKey);
|
||||||
|
const bound = bytes(keyPrefix(primary.body),keyPrefix(signingKey.body));
|
||||||
|
const crossCertification = signature(signingKey,0x19,bound);
|
||||||
|
const binding = signature(primary,0x18,bound,{extra:[subpacket(27,[2]),subpacket(32,crossCertification)]});
|
||||||
|
publicPackets = bytes(publicPackets,packet(14,signingKey.body),packet(2,binding));
|
||||||
|
}
|
||||||
|
if (!signers.length) signers.push(primary);
|
||||||
|
const signData = (signer,data) => armor('SIGNATURE',packet(2,signature(signer,0x00,Buffer.from(data),{created:created+5})));
|
||||||
|
return {
|
||||||
|
primaryFingerprint:primary.fingerprint.toString('hex').toUpperCase(),
|
||||||
|
signerFingerprint:signers[0].fingerprint.toString('hex').toUpperCase(),
|
||||||
|
signerFingerprints:signers.map(signer=>signer.fingerprint.toString('hex').toUpperCase()),
|
||||||
|
publicKey:armor('PUBLIC KEY BLOCK',publicPackets),
|
||||||
|
// Sign in the key's valid past lifetime, so verification must notice expiry
|
||||||
|
// or revocation now rather than merely reject an impossible creation date.
|
||||||
|
sign:(data,index = 0) => {if(!signers[index]) throw Error('Unknown fixture signer.');return signData(signers[index],data);},
|
||||||
|
// Deliberately valid text-mode signature for exact-byte policy rejection tests.
|
||||||
|
signText:(data,index = 0) => {if(!signers[index]) throw Error('Unknown fixture signer.');return armor('SIGNATURE',packet(2,signature(signers[index],0x01,Buffer.from(String(data).replace(/\r?\n/g,'\r\n')),{created:created+5})));},
|
||||||
|
signWithPrimary:data => signData(primary,data),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {readFile} from 'node:fs/promises';
|
||||||
|
import {createHash, generateKeyPairSync} from 'node:crypto';
|
||||||
|
import {FallbackUnavailableError, KEY_SOURCE_LIMITS, makeKeyBundle, parsePublicKeys, resolveKeys, validateFallbackSources} from '../cf-worker/src/key-source.js';
|
||||||
|
import {SIGNED_BUNDLE_MAX_BYTES, makeSignedDocument, SIGNATURE_SEPARATOR} from '../cf-worker/src/signed-release.js';
|
||||||
|
|
||||||
|
const fixture = await readFile(new URL('./fixtures/keys.pub', import.meta.url), 'utf8');
|
||||||
|
const canonical = fixture.trim().split(/\s+/).slice(0, 2).join(' ') + '\n';
|
||||||
|
const revision = 'a'.repeat(40);
|
||||||
|
const urls = ['https://keys.example.org/first.keys', 'https://keys.example.org/second.keys', 'https://keys.example.org/third.keys'];
|
||||||
|
const emptyRelease = {keys: [], allowEmpty: false, revision, fallbackSources: urls};
|
||||||
|
const fixtureKeys = await parsePublicKeys(fixture);
|
||||||
|
const hash = text => createHash('sha256').update(text).digest('hex');
|
||||||
|
const string = value => {
|
||||||
|
const bytes = typeof value === 'string' ? Buffer.from(value) : Buffer.from(value);
|
||||||
|
const prefix = Buffer.alloc(4); prefix.writeUInt32BE(bytes.length);
|
||||||
|
return Buffer.concat([prefix, bytes]);
|
||||||
|
};
|
||||||
|
const wire = (...fields) => Buffer.concat(fields.map(string));
|
||||||
|
const keyLine = (type, bytes, comment = '') => `${type} ${bytes.toString('base64')}${comment ? ` ${comment}` : ''}\n`;
|
||||||
|
const secondFixture = keyLine('ssh-ed25519', wire('ssh-ed25519', Buffer.from('3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c', 'hex')), 'second');
|
||||||
|
const keyData = Buffer.from(fixture.split(/\s+/)[1], 'base64');
|
||||||
|
const response = text => new Response(text, {headers: {'Content-Type': 'text/plain; charset=utf-8'}});
|
||||||
|
async function unavailable(fetcher, options = {}, release = emptyRelease) {
|
||||||
|
let caught;
|
||||||
|
try { await resolveKeys(release, fetcher, options); } catch (error) { caught = error; }
|
||||||
|
assert.ok(caught instanceof FallbackUnavailableError);
|
||||||
|
assert.equal(caught.code, 'KEY_SOURCES_UNAVAILABLE');
|
||||||
|
return caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('repository keys win without any upstream request and have complete protocol metadata', async () => {
|
||||||
|
const result = await resolveKeys({...emptyRelease, keys: fixtureKeys}, () => assert.fail('Repository keys must not fetch'));
|
||||||
|
assert.deepEqual(result.source, {type: 'repository'});
|
||||||
|
assert.equal(result.revision, revision);
|
||||||
|
assert.equal(result.keyCount, 1);
|
||||||
|
assert.equal(result.sha256, hash(canonical));
|
||||||
|
assert.equal(result.keyRevision, result.sha256);
|
||||||
|
assert.equal(result.keysText, `# conf-sshd-v1\n# revision ${revision}\n# key-count 1\n# allow-empty no\n# sha256 ${hash(canonical)}\n# end-metadata\n${canonical}`);
|
||||||
|
assert.ok(Number.isFinite(Date.parse(result.fetchedAt)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explicit empty revocation suppresses fallback; repository still wins when nonempty', async () => {
|
||||||
|
const result = await resolveKeys({...emptyRelease, allowEmpty: true}, () => assert.fail('Revocation must not fetch'));
|
||||||
|
assert.deepEqual(result.source, {type: 'explicit-empty'});
|
||||||
|
assert.equal(result.keyCount, 0);
|
||||||
|
assert.equal(result.sha256, hash(''));
|
||||||
|
assert.match(result.keysText, /# allow-empty yes\n/);
|
||||||
|
const populated = await resolveKeys({...emptyRelease, keys: fixtureKeys, allowEmpty: true}, () => assert.fail('Must not fetch'));
|
||||||
|
assert.equal(populated.source.type, 'repository');
|
||||||
|
assert.equal(populated.keyCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('first valid nonempty source wins in order, without aggregation or redirect/cache reuse', async () => {
|
||||||
|
const visited = [];
|
||||||
|
const result = await resolveKeys(emptyRelease, async (url, init) => {
|
||||||
|
visited.push(url);
|
||||||
|
assert.equal(init.cache, 'no-store'); assert.equal(init.redirect, 'manual'); assert.equal(init.method, 'GET');
|
||||||
|
assert.match(init.headers['Cache-Control'], /no-store/); assert.ok(init.signal instanceof AbortSignal);
|
||||||
|
return response(fixture);
|
||||||
|
});
|
||||||
|
assert.deepEqual(visited, [urls[0]]);
|
||||||
|
assert.deepEqual(result.source, {type: 'fallback', url: urls[0]});
|
||||||
|
assert.equal(result.keyCount, 1);
|
||||||
|
assert.equal(result.sha256, hash(canonical));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed and empty sources are skipped before the next valid source', async () => {
|
||||||
|
const visited = [];
|
||||||
|
const result = await resolveKeys(emptyRelease, async url => {
|
||||||
|
visited.push(url);
|
||||||
|
return response([ '<html>upstream problem</html>', '# no keys\n\n', fixture ][urls.indexOf(url)]);
|
||||||
|
});
|
||||||
|
assert.deepEqual(visited, urls);
|
||||||
|
assert.equal(result.source.url, urls[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const status of [301, 302, 307, 308, 400, 404, 429, 500, 503]) {
|
||||||
|
test(`HTTP ${status} is skipped without following redirects`, async () => {
|
||||||
|
const visited = [];
|
||||||
|
const result = await resolveKeys(emptyRelease, async url => {
|
||||||
|
visited.push(url);
|
||||||
|
return url === urls[0] ? new Response('Do not return this body: SECRET', {status, headers: {Location: 'https://unconfigured.example/keys'}}) : response(fixture);
|
||||||
|
});
|
||||||
|
assert.deepEqual(visited, urls.slice(0, 2));
|
||||||
|
assert.equal(result.source.url, urls[1]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a response reported as redirected is rejected even when status is 200', async () => {
|
||||||
|
const redirected = response(fixture);
|
||||||
|
Object.defineProperty(redirected, 'redirected', {value: true});
|
||||||
|
const result = await resolveKeys(emptyRelease, async url => url === urls[0] ? redirected : response(secondFixture));
|
||||||
|
assert.equal(result.source.url, urls[1]);
|
||||||
|
assert.equal(result.sha256, hash(secondFixture.trim().split(/\s+/).slice(0, 2).join(' ') + '\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetch timeout is bounded even when an upstream ignores AbortSignal', async () => {
|
||||||
|
const visited = [];
|
||||||
|
const result = await resolveKeys(emptyRelease, async url => {
|
||||||
|
visited.push(url);
|
||||||
|
if (url === urls[0]) return new Promise(() => {});
|
||||||
|
return response(fixture);
|
||||||
|
}, {timeoutMs: 20});
|
||||||
|
assert.deepEqual(visited, urls.slice(0, 2));
|
||||||
|
assert.equal(result.source.url, urls[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timeout includes streamed body and cancels it before falling back', async () => {
|
||||||
|
let cancelled = false;
|
||||||
|
const body = new ReadableStream({start(controller) {controller.enqueue(new TextEncoder().encode(fixture));}, cancel() {cancelled = true;}});
|
||||||
|
const result = await resolveKeys(emptyRelease, async url => url === urls[0] ? new Response(body) : response(secondFixture), {timeoutMs: 20});
|
||||||
|
assert.equal(cancelled, true);
|
||||||
|
assert.equal(result.source.url, urls[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all failed sources return typed, bounded diagnostics without secret bodies or errors', async () => {
|
||||||
|
const error = await unavailable(async url => {
|
||||||
|
if (url === urls[0]) throw new Error('Credential SECRET network detail');
|
||||||
|
if (url === urls[1]) return new Response('SECRET body', {status: 503});
|
||||||
|
return response('# no keys\n');
|
||||||
|
});
|
||||||
|
assert.deepEqual(error.diagnostics, [{index: 0, code: 'network-error'}, {index: 1, code: 'http-error', status: 503}, {index: 2, code: 'empty'}]);
|
||||||
|
assert.doesNotMatch(JSON.stringify(error), /SECRET|https:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no repository keys and no configured sources is unavailable without fetching', async () => {
|
||||||
|
const error = await unavailable(() => assert.fail('No implicit GitHub or other source'), {}, {...emptyRelease, fallbackSources: []});
|
||||||
|
assert.deepEqual(error.diagnostics, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('streamed size is enforced even without or with a misleading Content-Length', async () => {
|
||||||
|
for (const headers of [{}, {'Content-Length': '1'}]) {
|
||||||
|
let cancelled = false;
|
||||||
|
const body = new ReadableStream({start(controller) {controller.enqueue(new Uint8Array(70)); controller.enqueue(new Uint8Array(70));}, cancel() {cancelled = true;}});
|
||||||
|
const error = await unavailable(async () => new Response(body, {headers}), {maxBytes: 128}, {...emptyRelease, fallbackSources: [urls[0]]});
|
||||||
|
assert.equal(error.diagnostics[0].code, 'too-large');
|
||||||
|
assert.equal(cancelled, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('oversized declared length is rejected without reading the body', async () => {
|
||||||
|
const error = await unavailable(async () => new Response(fixture, {headers: {'Content-Length': String(SIGNED_BUNDLE_MAX_BYTES + 1)}}), {}, {...emptyRelease, fallbackSources: [urls[0]]});
|
||||||
|
assert.equal(error.diagnostics[0].code, 'too-large');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid UTF-8 is rejected instead of silently replaced', async () => {
|
||||||
|
const error = await unavailable(async () => new Response(Uint8Array.from([0xc3, 0x28])), {}, {...emptyRelease, fallbackSources: [urls[0]]});
|
||||||
|
assert.equal(error.diagnostics[0].code, 'invalid-utf8');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changing upstream changes content revision while build revision stays fixed; no stale fallback', async () => {
|
||||||
|
let body = fixture;
|
||||||
|
const fetcher = async () => response(body);
|
||||||
|
const release = {...emptyRelease, fallbackSources: [urls[0]]};
|
||||||
|
const first = await resolveKeys(release, fetcher);
|
||||||
|
body = secondFixture;
|
||||||
|
const second = await resolveKeys(release, fetcher);
|
||||||
|
assert.notEqual(first.keyRevision, second.keyRevision);
|
||||||
|
assert.equal(first.revision, second.revision);
|
||||||
|
body = '';
|
||||||
|
await assert.rejects(resolveKeys(release, fetcher), FallbackUnavailableError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('octet-stream responses are accepted only after strict key parsing', async () => {
|
||||||
|
const result = await resolveKeys(emptyRelease, async () => new Response(fixture, {headers: {'Content-Type': 'application/octet-stream'}}));
|
||||||
|
assert.equal(result.keyCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sources are trusted HTTPS config only, bounded, without credentials or fragments', () => {
|
||||||
|
assert.deepEqual(validateFallbackSources(urls), urls);
|
||||||
|
assert.deepEqual(validateFallbackSources(), []);
|
||||||
|
for (const invalid of [null, {}, new Array(6).fill(urls[0]), ['http://example.org/key'], ['https://user:[email protected]/key'], ['https://example.org/key#fragment'], ['https://example.org/key#'], ['https://example.org/key\n'], ['data:text/plain,key'], ['//example.org/key']]) assert.throws(() => validateFallbackSources(invalid));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Unicode comments and CRLF are canonicalized, with a known SHA256 fingerprint', async () => {
|
||||||
|
const input = fixture.trim().split(/\s+/).slice(0, 2).join('\t') + '\t 家用服务器 🔐\t张三\r\n';
|
||||||
|
const [key] = await parsePublicKeys(input);
|
||||||
|
assert.equal(key.label, '家用服务器 🔐 张三');
|
||||||
|
assert.equal(key.fingerprint, 'SHA256:bbXpuKG6zhzdmnxq256TlqzFBzRl2f6OOg722cYNbU8');
|
||||||
|
assert.equal(key.text, `${fixture.trim().split(/\s+/).slice(0, 2).join(' ')} 家用服务器 🔐 张三`);
|
||||||
|
const bundle = await makeKeyBundle([key], revision, false);
|
||||||
|
assert.equal(bundle.sha256, hash(canonical));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicates, options, certificates, private keys, invalid controls, and junk are rejected', async () => {
|
||||||
|
const cert = fixture.replaceAll('ssh-ed25519', '[email protected]');
|
||||||
|
for (const invalid of [fixture + fixture, `restrict ${fixture}`, `command="id" ${fixture}`, cert, '-----BEGIN OPENSSH PRIVATE KEY-----\n', fixture + '<html>oops</html>', fixture.replace('TEST-ONLY', 'bad\u0000comment'), fixture.replace('TEST-ONLY', 'bad\rcomment'), fixture.replace('TEST-ONLY', 'bad\u0085comment'), fixture.replace('TEST-ONLY', 'bad\ud800comment')]) {
|
||||||
|
await assert.rejects(parsePublicKeys(invalid));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wrong wire type, truncated fields, trailing data, noncanonical base64, invalid key sizes fail', async () => {
|
||||||
|
const wrongType = keyLine('ssh-rsa', keyData);
|
||||||
|
const truncated = keyLine('ssh-ed25519', keyData.subarray(0, keyData.length - 1));
|
||||||
|
const trailing = keyLine('ssh-ed25519', Buffer.concat([keyData, Buffer.from([0])]));
|
||||||
|
const short = keyLine('ssh-ed25519', wire('ssh-ed25519', Buffer.alloc(31)));
|
||||||
|
const long = keyLine('ssh-ed25519', wire('ssh-ed25519', Buffer.alloc(33)));
|
||||||
|
const badBase64 = 'ssh-ed25519 A===\n';
|
||||||
|
const hugeField = keyLine('ssh-ed25519', Buffer.from([255, 255, 255, 255]));
|
||||||
|
for (const input of [wrongType, truncated, trailing, short, long, badBase64, hugeField]) await assert.rejects(parsePublicKeys(input));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('public key count and line/input size limits are enforced', async () => {
|
||||||
|
await assert.rejects(parsePublicKeys(fixture + secondFixture, {maxKeys: 1}), /Too many/);
|
||||||
|
await assert.rejects(parsePublicKeys(fixture.trim() + 'x'.repeat(8192)), /line exceeds/);
|
||||||
|
await assert.rejects(parsePublicKeys('#'.repeat(KEY_SOURCE_LIMITS.maxBytes + 1)), /size limit/);
|
||||||
|
await assert.rejects(resolveKeys(emptyRelease, () => assert.fail(), {timeoutMs: KEY_SOURCE_LIMITS.timeoutMs + 1}), /limit/);
|
||||||
|
});
|
||||||
|
|
||||||
|
function mpint(bytes) {return bytes[0] & 128 ? Buffer.concat([Buffer.from([0]), bytes]) : bytes;}
|
||||||
|
function rsaLine(jwk, exponent, modulus) {
|
||||||
|
return keyLine('ssh-rsa', wire('ssh-rsa', exponent || mpint(Buffer.from(jwk.e, 'base64url')), modulus || mpint(Buffer.from(jwk.n, 'base64url'))));
|
||||||
|
}
|
||||||
|
test('RSA requires complete positive canonical mpints and at least 2048 bits', async () => {
|
||||||
|
const {publicKey} = generateKeyPairSync('rsa', {modulusLength: 2048});
|
||||||
|
const jwk = publicKey.export({format: 'jwk'});
|
||||||
|
assert.equal((await parsePublicKeys(rsaLine(jwk)))[0].type, 'ssh-rsa');
|
||||||
|
const modulus = mpint(Buffer.from(jwk.n, 'base64url'));
|
||||||
|
for (const [e, n] of [[Buffer.from([0, 3]), modulus], [Buffer.from([128]), modulus], [Buffer.from([2]), modulus], [Buffer.from([1]), modulus], [Buffer.from([3]), Buffer.from([0])], [Buffer.from([3]), Buffer.from([127, 3])], [Buffer.from([3]), modulus.subarray(1)], [Buffer.from([3]), Buffer.concat([Buffer.from([0]), modulus])]]) await assert.rejects(parsePublicKeys(rsaLine(jwk, e, n)));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [name, curve] of [['nistp256', 'P-256'], ['nistp384', 'P-384'], ['nistp521', 'P-521']]) {
|
||||||
|
test(`ECDSA ${name} requires a matching curve and a valid point`, async () => {
|
||||||
|
const {publicKey} = generateKeyPairSync('ec', {namedCurve: curve});
|
||||||
|
const jwk = publicKey.export({format: 'jwk'});
|
||||||
|
const type = `ecdsa-sha2-${name}`;
|
||||||
|
const point = Buffer.concat([Buffer.from([4]), Buffer.from(jwk.x, 'base64url'), Buffer.from(jwk.y, 'base64url')]);
|
||||||
|
assert.equal((await parsePublicKeys(keyLine(type, wire(type, name, point))))[0].type, type);
|
||||||
|
await assert.rejects(parsePublicKeys(keyLine(type, wire(type, 'wrong', point))));
|
||||||
|
await assert.rejects(parsePublicKeys(keyLine(type, wire(type, name, point.subarray(1)))));
|
||||||
|
const offCurve = Buffer.alloc(point.length); offCurve[0] = 4;
|
||||||
|
await assert.rejects(parsePublicKeys(keyLine(type, wire(type, name, offCurve))), /Invalid ECDSA point/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('OpenSSH security-key Ed25519 and ECDSA formats validate applications and trailing data', async () => {
|
||||||
|
const edType = '[email protected]';
|
||||||
|
const publicBytes = keyData.subarray(keyData.length - 32);
|
||||||
|
const ed = keyLine(edType, wire(edType, publicBytes, 'ssh:example'));
|
||||||
|
assert.equal((await parsePublicKeys(ed))[0].type, edType);
|
||||||
|
await assert.rejects(parsePublicKeys(keyLine(edType, wire(edType, publicBytes, ''))));
|
||||||
|
await assert.rejects(parsePublicKeys(keyLine(edType, wire(edType, publicBytes, 'ssh:\u0000'))));
|
||||||
|
await assert.rejects(parsePublicKeys(keyLine(edType, wire(edType, publicBytes))));
|
||||||
|
const {publicKey} = generateKeyPairSync('ec', {namedCurve: 'P-256'});
|
||||||
|
const jwk = publicKey.export({format: 'jwk'});
|
||||||
|
const ecType = '[email protected]';
|
||||||
|
const point = Buffer.concat([Buffer.from([4]), Buffer.from(jwk.x, 'base64url'), Buffer.from(jwk.y, 'base64url')]);
|
||||||
|
assert.equal((await parsePublicKeys(keyLine(ecType, wire(ecType, 'nistp256', point, 'ssh:example'))))[0].type, ecType);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bundle rejects implicit empty and invalid deployment metadata', async () => {
|
||||||
|
await assert.rejects(makeKeyBundle([], revision, false));
|
||||||
|
await assert.rejects(makeKeyBundle(fixtureKeys, 'not-a-revision', false));
|
||||||
|
await assert.rejects(makeKeyBundle(fixtureKeys, revision, 'yes'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fallback URL configuration rejects queries, local/private names, and normalized duplicates', () => {
|
||||||
|
for (const url of ['https://keys.example.org/key?token=secret', 'https://keys.example.org/key?', 'https://localhost/key', 'https://thing.local/key', 'https://metadata.google.internal/key', 'https://home.lan/key', 'https://192.168.1.1/key', 'https://127.1/key', 'https://[::1]/key', 'https://[2001:db8::1]/key', 'https://2130706433/key', 'https://0x7f000001/key', 'https://single-label/key']) assert.throws(() => validateFallbackSources([url]));
|
||||||
|
assert.throws(() => validateFallbackSources(['https://KEYS.example.org:443/key', 'https://keys.example.org/key']), /Duplicate/);
|
||||||
|
assert.deepEqual(validateFallbackSources(['https://KEYS.example.org:443/key']), ['https://keys.example.org/key']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('public-key parser honors an aborted signal before and during validation', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
await assert.rejects(parsePublicKeys(fixture, {signal: controller.signal}), /timeout/);
|
||||||
|
let reads = 0;
|
||||||
|
const signal = {get aborted() {return ++reads >= 3;}};
|
||||||
|
await assert.rejects(parsePublicKeys(fixture + secondFixture, {signal}), /timeout/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('whole serialized protocol, including metadata, is limited to the client response cap', async () => {
|
||||||
|
const text = 'x'.repeat(KEY_SOURCE_LIMITS.maxBytes - 100);
|
||||||
|
await assert.rejects(makeKeyBundle([{text}], revision, false), /too-large/);
|
||||||
|
const bundle = await makeKeyBundle([{text: 'x'.repeat(KEY_SOURCE_LIMITS.maxBytes - 300)}], revision, false);
|
||||||
|
assert.ok(Buffer.byteLength(bundle.text) <= KEY_SOURCE_LIMITS.maxBytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('large upstream comments are stripped before publication without leaking labels', async () => {
|
||||||
|
const publicBytes = keyData.subarray(keyData.length - 32);
|
||||||
|
const lines = Array.from({length: 128}, (_, index) => {
|
||||||
|
const bytes = Buffer.from(publicBytes); bytes[31] ^= index;
|
||||||
|
const prefix = keyLine('ssh-ed25519', wire('ssh-ed25519', bytes)).trim() + ' ';
|
||||||
|
return prefix + 'x'.repeat(8190 - prefix.length) + '\n';
|
||||||
|
}).join('');
|
||||||
|
assert.ok(Buffer.byteLength(lines) <= KEY_SOURCE_LIMITS.maxBytes);
|
||||||
|
const output = await resolveKeys({...emptyRelease, fallbackSources: [urls[0]]}, async () => response(lines));
|
||||||
|
assert.equal(output.keyCount, 128);
|
||||||
|
assert.ok(output.keysText.length < 20000);
|
||||||
|
assert.equal(output.keys[0].label, 'Server key 1');
|
||||||
|
const result = await resolveKeys(emptyRelease, async url => response(url === urls[0] ? lines : fixture));
|
||||||
|
assert.equal(result.source.url, urls[0]);
|
||||||
|
assert.equal(result.keyCount, 128);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ordinary private-key advice in comments is allowed, while real private formats are rejected anywhere', async () => {
|
||||||
|
const advice = '# Do not copy private keys into this repository.\n';
|
||||||
|
assert.deepEqual(await parsePublicKeys(advice), []);
|
||||||
|
const publicWithComment = fixture.trim().split(/\s+/).slice(0, 2).join(' ') + ' private key remains on my laptop\n';
|
||||||
|
assert.equal((await parsePublicKeys(advice + publicWithComment))[0].label, 'private key remains on my laptop');
|
||||||
|
for (const marker of ['-----BEGIN OPENSSH PRIVATE KEY-----', '-----BEGIN RSA PRIVATE KEY-----', '-----BEGIN EC PRIVATE KEY-----', '-----BEGIN PRIVATE KEY-----', '-----BEGIN ENCRYPTED PRIVATE KEY-----', 'PuTTY-User-Key-File-3: ssh-ed25519']) {
|
||||||
|
await assert.rejects(parsePublicKeys('# ' + marker + '\n' + fixture), /Private keys are never accepted/);
|
||||||
|
await assert.rejects(parsePublicKeys(publicWithComment.trim() + ' ' + marker + '\n'), /Private keys are never accepted/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const gistSource = {type:'gist',id:'a'.repeat(32),ownerId:12345,ownerLogin:'original-login',file:'ssh.keys',signatureFile:'ssh.keys.sig',bundleFile:'release.bundle'};
|
||||||
|
const gistRevision = 'b'.repeat(40);
|
||||||
|
const gistRelease = {...emptyRelease,keySource:gistSource};
|
||||||
|
const signedPolicy = {listId:'production',sequence:9,issuedAt:100,expiresAt:300};
|
||||||
|
const signedDocument = await makeSignedDocument(fixture,signedPolicy);
|
||||||
|
// Syntactic fixture only, deliberately NOT a valid cryptographic signature. Worker never makes that claim.
|
||||||
|
const signatureFixture = '-----BEGIN PGP SIGNATURE-----\n\nYWJj\n-----END PGP SIGNATURE-----\n';
|
||||||
|
const signedBundleFixture = signedDocument + SIGNATURE_SEPARATOR + signatureFixture;
|
||||||
|
const gistFile = (filename,content) => ({filename,content,size:Buffer.byteLength(content),truncated:false,encoding:'utf-8',raw_url:'https://untrusted.invalid/ignored'});
|
||||||
|
const gistData = (files={}) => ({id:gistSource.id,owner:{id:gistSource.ownerId,login:'renamed-login'},public:true,truncated:false,history:[{version:gistRevision}],files});
|
||||||
|
const jsonResponse = data => response(JSON.stringify(data));
|
||||||
|
const gistUrl = `https://api.github.com/gists/${gistSource.id}`;
|
||||||
|
|
||||||
|
for (const status of [201,204,206]) test(`non-200 success HTTP ${status} is not a key release`, async () => {
|
||||||
|
const result=await resolveKeys(emptyRelease,async url=>url===urls[0]?new Response(status===204?null:fixture,{status}):response(fixture));
|
||||||
|
assert.equal(result.source.url,urls[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Git signed document is authoritative and exact, while missing/bad signature does not block OFF', async () => {
|
||||||
|
for (const signature of [null,'bad armor',signatureFixture]) {
|
||||||
|
const result=await resolveKeys({...emptyRelease,keys:fixtureKeys,signedDocument,signature},()=>assert.fail('No network'));
|
||||||
|
assert.equal(result.keysText,signedDocument); assert.equal(result.signedDocument,signedDocument);
|
||||||
|
assert.equal(result.signature,signature===signatureFixture?signatureFixture:null);
|
||||||
|
assert.equal(result.keyRevision,hash(signedDocument)); assert.equal(result.sha256,hash(canonical));
|
||||||
|
assert.equal(result.signedMetadata.sequence,9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signed empty revocation suppresses fallback even when legacy allowEmpty is false', async () => {
|
||||||
|
const document=await makeSignedDocument('',{...signedPolicy,revoke:true});
|
||||||
|
const result=await resolveKeys({...emptyRelease,signedDocument:document,signature:signatureFixture},()=>assert.fail('Revocation must not fetch'));
|
||||||
|
assert.equal(result.keyCount,0);assert.equal(result.allowEmpty,true);assert.equal(result.source.type,'explicit-empty');
|
||||||
|
assert.equal(result.keysText,document);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strict sources never downgrade to raw keys; ordered signed fallback chooses one entire bundle', async () => {
|
||||||
|
const visited=[];
|
||||||
|
const result=await resolveKeys({...emptyRelease,keys:fixtureKeys},async url=>{visited.push(url);return response(url===urls[0]?fixture:signedBundleFixture);},{requireSigned:true,now:200});
|
||||||
|
assert.deepEqual(visited,urls.slice(0,2));assert.equal(result.source.url,urls[1]);assert.equal(result.signature,signatureFixture);
|
||||||
|
assert.equal(result.keysText,signedDocument);assert.equal(result.signedBundle,signedBundleFixture);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fallback standalone signed document gets only its same-source detached signature', async () => {
|
||||||
|
const visited=[];
|
||||||
|
const result=await resolveKeys(emptyRelease,async url=>{visited.push(url);return response(url.endsWith('.sig')?signatureFixture:signedDocument);},{requireSigned:true,now:200});
|
||||||
|
assert.deepEqual(visited,[urls[0],urls[0]+'.sig']);assert.equal(result.signature,signatureFixture);assert.equal(result.keysText,signedDocument);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing, non200, malformed or hung optional signature still serves the valid OFF document', async () => {
|
||||||
|
for (const mode of ['missing','non200','bad','fetch-timeout','body-timeout']) {
|
||||||
|
let cancelled=false;
|
||||||
|
const body=new ReadableStream({start(c){c.enqueue(new TextEncoder().encode('part'));},cancel(){cancelled=true;}});
|
||||||
|
const fetcher=async url=> {
|
||||||
|
if (!url.endsWith('.sig')) return response(signedDocument);
|
||||||
|
if(mode==='missing')return new Response('missing',{status:404});
|
||||||
|
if(mode==='non200')return new Response(signatureFixture,{status:206});
|
||||||
|
if(mode==='bad')return response('bad');
|
||||||
|
if(mode==='fetch-timeout')return new Promise(()=>{});
|
||||||
|
return new Response(body);
|
||||||
|
};
|
||||||
|
const result=await resolveKeys(emptyRelease,fetcher,{timeoutMs:20});
|
||||||
|
assert.equal(result.keysText,signedDocument,mode);assert.equal(result.signature,null,mode);
|
||||||
|
if(mode==='body-timeout')assert.equal(cancelled,true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strict signature failure stays with its source before trying next whole signed release', async () => {
|
||||||
|
const result=await resolveKeys(emptyRelease,async url=>url===urls[0]?response(signedDocument):url===urls[0]+'.sig'?new Response('',{status:404}):response(signedBundleFixture),{requireSigned:true,now:200});
|
||||||
|
assert.equal(result.source.url,urls[1]);assert.equal(result.signedBundle,signedBundleFixture);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Gist fetch uses numeric-owner pin and immutable revision, never latest contents or raw_url', async () => {
|
||||||
|
const visited=[];
|
||||||
|
const result=await resolveKeys(gistRelease,async (url,init)=>{
|
||||||
|
visited.push(url);assert.equal(init.headers['User-Agent'],'conf-sshd-key-source');assert.equal(init.redirect,'manual');assert.equal(init.cache,'no-store');
|
||||||
|
if(url===gistUrl)return jsonResponse(gistData({'ssh.keys':gistFile('ssh.keys',secondFixture)}));
|
||||||
|
assert.equal(url,gistUrl+'/'+gistRevision);
|
||||||
|
return jsonResponse(gistData({'ssh.keys':gistFile('ssh.keys',fixture)}));
|
||||||
|
});
|
||||||
|
assert.deepEqual(visited,[gistUrl,gistUrl+'/'+gistRevision]);
|
||||||
|
assert.equal(result.sha256,hash(canonical));assert.equal(result.source.type,'gist');assert.equal(result.source.ownerId,12345);
|
||||||
|
assert.equal(result.source.ownerLogin,'original-login');assert.equal(result.source.gistRevision,gistRevision);
|
||||||
|
assert.equal(result.signature,null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Gist signed document and signature are extracted from one pinned revision without transforms', async () => {
|
||||||
|
let calls=0;
|
||||||
|
const result=await resolveKeys(gistRelease,async url=>{calls++;return jsonResponse(gistData(url===gistUrl?{}:{'ssh.keys':gistFile('ssh.keys',signedDocument),'ssh.keys.sig':gistFile('ssh.keys.sig',signatureFixture)}));},{requireSigned:true,now:200});
|
||||||
|
assert.equal(calls,2);assert.equal(result.keysText,signedDocument);assert.equal(result.signature,signatureFixture);assert.equal(result.keyRevision,hash(signedDocument));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Gist bundle has one authoritative document and explicit revocation cannot be refilled', async () => {
|
||||||
|
const revoke=await makeSignedDocument('',{...signedPolicy,revoke:true});
|
||||||
|
const bundle=revoke+SIGNATURE_SEPARATOR+signatureFixture;
|
||||||
|
const result=await resolveKeys(gistRelease,async url=>{assert.ok(url.startsWith(gistUrl));return jsonResponse(gistData({'release.bundle':gistFile('release.bundle',bundle),'ssh.keys':gistFile('ssh.keys',fixture)}));});
|
||||||
|
assert.equal(result.keyCount,0);assert.equal(result.allowEmpty,true);assert.equal(result.keysText,revoke);assert.equal(result.source.file,'release.bundle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing/truncated/malformed Gist signature does not prevent an OFF document response', async () => {
|
||||||
|
for(const optional of [null,gistFile('ssh.keys.sig','bad'),{...gistFile('ssh.keys.sig',signatureFixture),truncated:true}]) {
|
||||||
|
const result=await resolveKeys(gistRelease,async()=>jsonResponse(gistData({'ssh.keys':gistFile('ssh.keys',signedDocument),...(optional?{'ssh.keys.sig':optional}:{})})));
|
||||||
|
assert.equal(result.keysText,signedDocument);assert.equal(result.signature,null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const when of ['latest','revision']) test(`Gist owner mismatch in ${when} fails closed before ordered fallback`, async () => {
|
||||||
|
const visited=[];
|
||||||
|
const result=await resolveKeys(gistRelease,async url=>{
|
||||||
|
visited.push(url);
|
||||||
|
if(url===urls[0])return response(fixture);
|
||||||
|
const data=gistData({'ssh.keys':gistFile('ssh.keys',fixture)});
|
||||||
|
if((when==='latest')===(url===gistUrl))data.owner.id=99999;
|
||||||
|
return jsonResponse(data);
|
||||||
|
});
|
||||||
|
assert.equal(result.source.type,'fallback');assert.equal(visited.length,when==='latest'?2:3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Gist rejects top-level truncation, private sources, invalid revision, selected-file truncation and byte mismatches', async () => {
|
||||||
|
const mutations=[data=>{data.truncated=true;},data=>{data.public=false;},data=>{data.history[0].version='../latest';},data=>{data.files['ssh.keys'].truncated=true;},data=>{data.files['ssh.keys'].size++;},data=>{data.files['ssh.keys'].filename='different';},data=>{data.files['ssh.keys'].encoding='base64';},data=>{data.id='b'.repeat(32);}];
|
||||||
|
for(const mutate of mutations){
|
||||||
|
const result=await resolveKeys(gistRelease,async url=>{if(url===urls[0])return response(fixture);const data=gistData({'ssh.keys':gistFile('ssh.keys',fixture)});mutate(data);return jsonResponse(data);});
|
||||||
|
assert.equal(result.source.type,'fallback');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Gist combined metadata/revision HTTP/body timeout is bounded and falls back without stale keys', async () => {
|
||||||
|
for(const mode of ['http','json','fetch','body']) {
|
||||||
|
let cancelled=false;
|
||||||
|
const body=new ReadableStream({start(c){c.enqueue(new TextEncoder().encode('{'));},cancel(){cancelled=true;}});
|
||||||
|
const result=await resolveKeys(gistRelease,async url=>{
|
||||||
|
if(url===urls[0])return response(fixture);
|
||||||
|
if(url===gistUrl)return jsonResponse(gistData());
|
||||||
|
if(mode==='http')return new Response('',{status:503});
|
||||||
|
if(mode==='json')return response('{');
|
||||||
|
if(mode==='fetch')return new Promise(()=>{});
|
||||||
|
return new Response(body);
|
||||||
|
},{timeoutMs:20});
|
||||||
|
assert.equal(result.source.type,'fallback');if(mode==='body')assert.equal(cancelled,true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Gist dynamic revision changes content digest while code revision stays fixed', async () => {
|
||||||
|
let current=gistRevision, text=fixture;
|
||||||
|
const fetcher=async url=>{const data=gistData({'ssh.keys':gistFile('ssh.keys',text)});data.history[0].version=current;assert.ok(url===gistUrl||url===gistUrl+'/'+current);return jsonResponse(data);};
|
||||||
|
const first=await resolveKeys(gistRelease,fetcher);
|
||||||
|
current='c'.repeat(40);text=secondFixture;
|
||||||
|
const second=await resolveKeys(gistRelease,fetcher);
|
||||||
|
assert.notEqual(first.sha256,second.sha256);assert.notEqual(first.source.gistRevision,second.source.gistRevision);assert.equal(first.revision,second.revision);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid Gist and signing config is rejected before any network request', async () => {
|
||||||
|
for(const keySource of [null,{type:'other'},{type:'git',url:'https://evil.example'}, {...gistSource,ownerId:'12345'}, {...gistSource,ownerId:0}, {...gistSource,id:'../escape'}, {...gistSource,ownerLogin:'bad/login'}, {...gistSource,file:'../key'}, {...gistSource,signatureFile:'ssh.keys'}, {...gistSource,token:'secret'}]) await assert.rejects(resolveKeys({...gistRelease,keySource},()=>assert.fail('Invalid config must not fetch')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Gist response bytes are bounded and no fallback response inherits upstream text or private data', async () => {
|
||||||
|
const result=await resolveKeys(gistRelease,async url=>url===urls[0]?response(fixture):new Response('{}',{headers:{'Content-Length':String(2*SIGNED_BUNDLE_MAX_BYTES+1)}}));
|
||||||
|
assert.equal(result.source.type,'fallback');assert.doesNotMatch(JSON.stringify(result),/TEST-ONLY/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('named Gist bundles cannot silently downgrade to raw key files', async () => {
|
||||||
|
for(const contents of [fixture,signedDocument]) {
|
||||||
|
const result=await resolveKeys(gistRelease,async url=>url===urls[0]?response(fixture):jsonResponse(gistData({'release.bundle':gistFile('release.bundle',contents)})));
|
||||||
|
assert.equal(result.source.type,'fallback');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Hermetic tests only. The network CLI is deliberately not invoked here.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {existsSync, readFileSync} from 'node:fs';
|
||||||
|
import {spawnSync} from 'node:child_process';
|
||||||
|
import {openPgpFixture} from './helpers/openpgp-fixture.mjs';
|
||||||
|
import {classifyFailure, discoveryProbes, runDiscovery, summaryMarkdown, validateCertificate, validateFixture, verifierHarness} from '../scripts/test-live-discovery.mjs';
|
||||||
|
|
||||||
|
const fixture = JSON.parse(readFileSync(new URL('./fixtures/live-discovery.json', import.meta.url), 'utf8'));
|
||||||
|
const clientSource = readFileSync(new URL('../conf-sshd.sh', import.meta.url), 'utf8');
|
||||||
|
const gpgAvailable = spawnSync('gpg', ['--version']).status === 0;
|
||||||
|
|
||||||
|
test('fixed public discovery identity is explicitly test-only and full-pinned', () => {
|
||||||
|
assert.equal(validateFixture(fixture), fixture);
|
||||||
|
assert.equal(fixture.email, '[email protected]');
|
||||||
|
assert.equal(fixture.primaryFingerprint, '7EF5FB1E65C1A6C6D02DFCDFCE57746D7E5EE6EF');
|
||||||
|
assert.equal(fixture.signingSubkeyFingerprint, 'B1B89449E5877614EA672F5F6C5AE2A913941E1D');
|
||||||
|
for (const change of [{primaryFingerprint: 'CE57746D7E5EE6EF'}, {testOnlyNotice: ''}, {email: 'bad\naddress'}, {keyservers: ['http://bad.example']}, {signingSubkeyFingerprint: fixture.primaryFingerprint}]) {
|
||||||
|
assert.throws(() => validateFixture({...fixture, ...change}), /Invalid/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discovery probes cover WKD and each HKPS independently using the full primary pin', () => {
|
||||||
|
const probes = discoveryProbes(fixture);
|
||||||
|
assert.equal(probes.length, 3);
|
||||||
|
assert.deepEqual(probes[0].args.slice(-3), ['--locate-external-keys', '--', fixture.email]);
|
||||||
|
for (const probe of probes.slice(1)) {
|
||||||
|
assert.deepEqual(probe.args.slice(-2), ['--recv-keys', fixture.primaryFingerprint]);
|
||||||
|
assert.equal(probe.queryMode, 'primary');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('network and local failures are classified without exposing diagnostic text', () => {
|
||||||
|
for (const [result, expected] of [
|
||||||
|
[{error: {code: 'ETIMEDOUT'}}, 'timeout'], [{signal: 'SIGKILL'}, 'timeout'],
|
||||||
|
[{error: {code: 'ENOENT'}}, 'missing-tool'], [{error: {code: 'EACCES'}}, 'process-error'],
|
||||||
|
[{stderr: 'gpg: Connection timed out'}, 'timeout'], [{stderr: 'Network is unreachable'}, 'network-unavailable'],
|
||||||
|
[{stderr: 'gpg: error: No data'}, 'key-not-found'], [{stderr: 'TLS certificate verification failed'}, 'transport-security-error'],
|
||||||
|
[{stderr: 'No dirmngr'}, 'local-agent-unavailable'], [{stderr: "can't create socket"}, 'local-agent-unavailable'],
|
||||||
|
[{stderr: 'failed to start dirmngr'}, 'local-agent-unavailable'], [{stderr: 'Operation not permitted'}, 'local-agent-unavailable'],
|
||||||
|
[{stderr: 'Private unrelated UID value'}, 'gpg-error'],
|
||||||
|
]) assert.equal(classifyFailure(result), expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unavailable network is a failing report, retries are bounded, and every attempt uses a fresh home', () => {
|
||||||
|
const homes = [], calls = [];
|
||||||
|
const report = runDiscovery(fixture, {
|
||||||
|
runner(command, args, options) {
|
||||||
|
calls.push({command, args, options});
|
||||||
|
if (command === 'gpgconf') return {status: 0};
|
||||||
|
homes.push(options.home);
|
||||||
|
assert.ok(options.timeout <= 15_000);
|
||||||
|
assert.ok(args.includes('--no-options'));
|
||||||
|
assert.ok(args.includes('--no-auto-key-retrieve'));
|
||||||
|
return {status: 2, stderr: 'Network is unreachable. Unrelated UID must never appear.'};
|
||||||
|
},
|
||||||
|
validator() { assert.fail('No certificate should be validated after a failed lookup.'); },
|
||||||
|
});
|
||||||
|
assert.equal(report.status, 'failed');
|
||||||
|
assert.equal(report.results.length, 3);
|
||||||
|
assert.ok(report.results.every(result => result.status === 'network-unavailable' && result.attempts === 2));
|
||||||
|
assert.equal(new Set(homes).size, 6);
|
||||||
|
assert.ok(homes.every(home => !existsSync(home)));
|
||||||
|
assert.equal(calls.filter(call => call.command === 'gpgconf').length, 6);
|
||||||
|
assert.doesNotMatch(JSON.stringify(report) + summaryMarkdown(report), /Unrelated UID/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing tool and exhausted budget fail rather than becoming skipped passes', () => {
|
||||||
|
const missing = runDiscovery(fixture, {runner: () => ({status: null, error: {code: 'ENOENT'}})});
|
||||||
|
assert.equal(missing.status, 'failed');
|
||||||
|
assert.ok(missing.results.every(result => result.status === 'missing-tool' && result.attempts === 1));
|
||||||
|
const localAgent = runDiscovery(fixture, {runner: () => ({status: 2, stderr: 'gpg: failed to start dirmngr'})});
|
||||||
|
assert.equal(localAgent.status, 'failed');
|
||||||
|
assert.ok(localAgent.results.every(result => result.status === 'local-agent-unavailable' && result.attempts === 1));
|
||||||
|
let ticks = 0;
|
||||||
|
const budget = runDiscovery(fixture, {now: () => ticks++ * 120_001, runner: () => assert.fail('Budget-exhausted test must not invoke tools.')});
|
||||||
|
assert.equal(budget.status, 'failed');
|
||||||
|
assert.ok(budget.results.every(result => result.status === 'budget-exhausted' && result.attempts === 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetched invalid certificates cannot be retried into apparent success', () => {
|
||||||
|
let validations = 0;
|
||||||
|
const report = runDiscovery(fixture, {
|
||||||
|
runner(command, args) {return {status: 0, stdout: args.includes('--export') ? 'untrusted certificate bytes' : ''};},
|
||||||
|
validator() { validations++; return {ok: false, status: 'certificate-pin-or-capability-rejected'}; },
|
||||||
|
});
|
||||||
|
assert.equal(report.status, 'failed');
|
||||||
|
assert.equal(validations, 3);
|
||||||
|
assert.ok(report.results.every(result => result.attempts === 1 && result.status === 'certificate-pin-or-capability-rejected'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successful discovery validates each exported certificate and reports no raw bytes', () => {
|
||||||
|
const certificates = [];
|
||||||
|
const report = runDiscovery(fixture, {
|
||||||
|
runner(command, args) {return {status: 0, stdout: args.includes('--export') ? 'public certificate fixture bytes' : ''};},
|
||||||
|
validator(certificate, config) {certificates.push(certificate);assert.equal(config, fixture);return {ok: true};},
|
||||||
|
});
|
||||||
|
assert.equal(report.status, 'passed');
|
||||||
|
assert.equal(certificates.length, 3);
|
||||||
|
assert.ok(report.results.every(result => result.status === 'passed'));
|
||||||
|
assert.doesNotMatch(JSON.stringify(report), /public certificate fixture bytes/);
|
||||||
|
assert.match(summaryMarkdown(report), /No user-signed key-list payload is tested/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifier harness extracts production certificate functions but never executes installer dispatch', () => {
|
||||||
|
const harness = verifierHarness(clientSource);
|
||||||
|
for (const name of ['new_gpg_home', 'gpg_local', 'import_pinned_key']) assert.ok(harness.includes(`${name}() {`));
|
||||||
|
for (const forbidden of ['install_dependency()', 'prepare_sshd_config()', 'case $mode in', 'load_local_config()']) assert.ok(!harness.includes(forbidden));
|
||||||
|
assert.throws(() => verifierHarness('not an installer'), /Cannot extract/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('actual client verifier accepts real local primary and signing-subkey pins and rejects unsafe certificates', {skip: !gpgAvailable}, () => {
|
||||||
|
const key = openPgpFixture({subkeys: 2});
|
||||||
|
const config = {...fixture, primaryFingerprint: key.primaryFingerprint, signingSubkeyFingerprint: key.signerFingerprint};
|
||||||
|
assert.equal(validateCertificate(key.publicKey, config).ok, true);
|
||||||
|
assert.equal(validateCertificate(key.publicKey, {...config, signingSubkeyFingerprint: key.signerFingerprints[1]}).ok, true);
|
||||||
|
assert.equal(validateCertificate(key.publicKey, {...config, primaryFingerprint: 'A'.repeat(40)}).ok, false);
|
||||||
|
assert.equal(validateCertificate(key.publicKey, {...config, signingSubkeyFingerprint: 'B'.repeat(40)}).ok, false);
|
||||||
|
for (const options of [{expired: true}, {revoked: true}]) {
|
||||||
|
const invalid = openPgpFixture({subkeys: 1, ...options});
|
||||||
|
assert.equal(validateCertificate(invalid.publicKey, {...fixture, primaryFingerprint: invalid.primaryFingerprint, signingSubkeyFingerprint: invalid.signerFingerprint}).ok, false);
|
||||||
|
}
|
||||||
|
const other = openPgpFixture({subkeys: 1});
|
||||||
|
assert.equal(validateCertificate(key.publicKey + other.publicKey, config).ok, false);
|
||||||
|
assert.equal(validateCertificate('', config).ok, false);
|
||||||
|
assert.equal(validateCertificate('x'.repeat(1024 * 1024 + 1), config).ok, false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
const candidates = [['POSIX sh', '/bin/sh'], ['bash POSIX mode', 'bash --posix'], ['BusyBox ash', 'busybox sh']];
|
||||||
|
for (const [label, shell] of candidates) {
|
||||||
|
const executable = shell.split(' ')[0];
|
||||||
|
const available = spawnSync(executable, executable === 'busybox' ? ['sh', '-c', ':'] : ['-c', ':']).status === 0;
|
||||||
|
test(`isolated installer regression suite (${label})`, { skip: !available }, () => {
|
||||||
|
const result = spawnSync('python3', ['tests/shell.test.py'], {
|
||||||
|
cwd: root, encoding: 'utf8', timeout: 90_000,
|
||||||
|
env: { ...process.env, CONF_SSHD_TEST_SHELL: shell },
|
||||||
|
});
|
||||||
|
assert.equal(result.error, undefined, result.error?.message);
|
||||||
|
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test('ShellCheck POSIX diagnostics', { skip: spawnSync('shellcheck', ['--version']).status !== 0 }, () => {
|
||||||
|
const result = spawnSync('shellcheck', ['--shell=sh', 'conf-sshd.sh'], { cwd: root, encoding: 'utf8' });
|
||||||
|
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||||
|
});
|
||||||
+1654
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,234 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {readFile, mkdtemp, writeFile, rm, cp, mkdir} from 'node:fs/promises';
|
||||||
|
import {tmpdir} from 'node:os';
|
||||||
|
import {join} from 'node:path';
|
||||||
|
import {createHash} from 'node:crypto';
|
||||||
|
import {execFileSync} from 'node:child_process';
|
||||||
|
import {makeSignedDocument, parseSignedDocument, parseSignedBundle, parsePublishedSignedRelease, SIGNATURE_SEPARATOR, SIGNED_RELEASE_LIMITS, validateSignature} from '../cf-worker/src/signed-release.js';
|
||||||
|
import {prepare} from '../scripts/prepare-key-release.mjs';
|
||||||
|
import {openPgpFixture} from './helpers/openpgp-fixture.mjs';
|
||||||
|
import {verifyRemote} from '../scripts/verify-release.mjs';
|
||||||
|
import {validateConfig, validateClientDefaults, build, root} from '../scripts/build.mjs';
|
||||||
|
import {validatePublicCertificate, checkSigningCertificate, validatePayloadSignatureStatus} from '../scripts/signature-tools.mjs';
|
||||||
|
|
||||||
|
const fixture = await readFile(new URL('./fixtures/keys.pub', import.meta.url), 'utf8');
|
||||||
|
const canonical = fixture.trim().split(/\s+/).slice(0, 2).join(' ') + '\n';
|
||||||
|
const policy = {listId: 'production', sequence: 1, issuedAt: 100, expiresAt: 300};
|
||||||
|
const document = await makeSignedDocument(fixture, policy);
|
||||||
|
// Structural fixture only: this armor is NOT a cryptographic signature and must never be trusted as one.
|
||||||
|
const signature = '-----BEGIN PGP SIGNATURE-----\n\nYWJj\n-----END PGP SIGNATURE-----\n';
|
||||||
|
|
||||||
|
test('preparation strips all key comments BEFORE signing; document bytes and metadata are exact', async () => {
|
||||||
|
assert.equal(document, '# conf-sshd-signed-v1\n# list-id production\n# sequence 1\n# issued-at 100\n# expires-at 300\n# revoke no\n# key-count 1\n# end-metadata\n' + canonical);
|
||||||
|
const parsed = await parseSignedDocument(document, {now: 200, listId: 'production'});
|
||||||
|
assert.equal(parsed.document, document);
|
||||||
|
assert.equal(parsed.keys[0].label, 'Server key 1');
|
||||||
|
assert.equal(parsed.metadata.documentSha256, createHash('sha256').update(document).digest('hex'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bundle parsing returns exact document and signature without implying cryptographic verification', async () => {
|
||||||
|
const bundle = document + SIGNATURE_SEPARATOR + signature;
|
||||||
|
const parsed = await parseSignedBundle(bundle, {now: 200});
|
||||||
|
assert.equal(parsed.bundle, bundle); assert.equal(parsed.document, document); assert.equal(parsed.signature, signature);
|
||||||
|
assert.equal(parsed.verified, undefined);
|
||||||
|
assert.equal(parsed.metadata.sequence, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signed documents reject comments, CRLF, normalization, duplicate keys, padding changes and trailing bytes', async () => {
|
||||||
|
for (const changed of [document.replace(canonical, fixture), document.replaceAll('\n','\r\n'), document.replace(canonical, canonical.replace(' ', '\t')), document.replace(canonical, canonical + canonical), document + '\n', document.slice(0, -1), document.replace(canonical, canonical + '# extra\n'), document.replace('key-count 1','key-count 2')]) await assert.rejects(parseSignedDocument(changed, {now:200}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('metadata is fixed-order, single-instance, canonical and bounded', async () => {
|
||||||
|
for (const [from,to] of [['sequence 1','sequence 0'], ['sequence 1','sequence 01'], ['sequence 1','sequence 2147483648'], ['issued-at 100','issued-at 0100'], ['expires-at 300','expires-at 100'], ['expires-at 300','expires-at 10000000000'], ['list-id production','list-id has space'], ['revoke no','revoke yes'], ['key-count 1','key-count 1001'], ['# end-metadata','# unknown value\n# end-metadata']]) await assert.rejects(parseSignedDocument(document.replace(from,to), {now:200}));
|
||||||
|
await assert.rejects(parseSignedDocument(document, {now:200,listId:'different'}));
|
||||||
|
await assert.rejects(parseSignedDocument(document + 'x'.repeat(SIGNED_RELEASE_LIMITS.documentBytes), {now:200}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('freshness rejects expiration and future issue, optional transport parsing does not enforce client freshness', async () => {
|
||||||
|
await assert.rejects(parseSignedDocument(document, {now:300}), /expired/);
|
||||||
|
const future = await makeSignedDocument(fixture, {...policy,issuedAt:501,expiresAt:800});
|
||||||
|
await assert.rejects(parseSignedDocument(future, {now:200}), /not yet/);
|
||||||
|
assert.equal((await parseSignedDocument(future, {now:201})).metadata.issuedAt, 501);
|
||||||
|
assert.equal((await parseSignedDocument(document, {now:999,checkFreshness:false})).metadata.sequence, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty revocation requires signed explicit revoke, no nonempty revoke accepted', async () => {
|
||||||
|
const empty = await makeSignedDocument('', {...policy,revoke:true});
|
||||||
|
assert.equal((await parseSignedDocument(empty, {now:200})).metadata.revoke, true);
|
||||||
|
for (const args of [['',policy], [fixture,{...policy,revoke:true}]]) await assert.rejects(makeSignedDocument(...args));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed or missing signatures do not block OFF documents but fail strict shape policy', async () => {
|
||||||
|
for (const invalid of [null, '', signature.replace('PGP SIGNATURE','PGP PRIVATE KEY BLOCK'), signature.slice(0,-1), signature + signature, 'x'.repeat(SIGNED_RELEASE_LIMITS.signatureBytes+1)]) {
|
||||||
|
const off = await parsePublishedSignedRelease(document, invalid, {checkFreshness:false});
|
||||||
|
assert.equal(off.document, document); assert.equal(off.signature,null); assert.equal(off.bundle,null);
|
||||||
|
await assert.rejects(parsePublishedSignedRelease(document, invalid, {now:200,requireSigned:true}));
|
||||||
|
}
|
||||||
|
assert.equal(validateSignature(signature), signature);
|
||||||
|
await assert.rejects(parseSignedBundle(document + SIGNATURE_SEPARATOR + signature + SIGNATURE_SEPARATOR, {now:200}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preparation CLI never overwrites source/output and packs without altering signed bytes', async () => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(),'conf-prep-'));
|
||||||
|
try {
|
||||||
|
const input=join(dir,'keys'), output=join(dir,'document'), sig=join(dir,'signature'), bundle=join(dir,'bundle');
|
||||||
|
await writeFile(input, fixture); await writeFile(sig, signature);
|
||||||
|
const args=['--input',input,'--output',output,'--list-id','production','--sequence','1','--issued-at','100','--expires-at','300'];
|
||||||
|
await prepare(args); assert.equal(await readFile(output,'utf8'),document);
|
||||||
|
await assert.rejects(prepare(args),/EEXIST/);
|
||||||
|
await prepare(['--input',output,'--signature',sig,'--bundle',bundle]);
|
||||||
|
assert.equal(await readFile(bundle,'utf8'),document+SIGNATURE_SEPARATOR+signature);
|
||||||
|
await assert.rejects(prepare([...args,'--sequence','2']));
|
||||||
|
await assert.rejects(prepare(args.map(value=>value==='100'?'0100':value)));
|
||||||
|
} finally { await rm(dir,{recursive:true,force:true}); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deployment client defaults are OFF and reject unsafe templates or incomplete ON pins', () => {
|
||||||
|
assert.deepEqual(validateClientDefaults(),{gpgVerification:'off',fingerprint:'',pinMode:'primary',listId:'',email:''});
|
||||||
|
assert.equal(validateClientDefaults({gpgVerification:'on',fingerprint:'a'.repeat(40),listId:'prod',email:'[email protected]'}).fingerprint,'A'.repeat(40));
|
||||||
|
for (const value of [null,{gpgVerification:'yes'},{gpgVerification:'on'},{fingerprint:'short'},{listId:'bad space'},{listId:'-'},{pinMode:'any'},{email:"a'@example.com"},{email:'[email protected]\n'},{unknown:1}]) assert.throws(()=>validateClientDefaults(value));
|
||||||
|
const base={baseUrl:'https://ssh.example.com',repositoryUrl:'https://github.com/example/repo',defaultCron:'*/15 * * * *',allowEmpty:false};
|
||||||
|
assert.doesNotThrow(()=>validateConfig({...base,signedRelease:{listId:'prod',publicKeyFile:'keys/certificate.asc',fingerprint:'A'.repeat(40)}}));
|
||||||
|
for (const signedRelease of [{listId:'prod',publicKeyFile:'../secret'}, {listId:'prod',fingerprint:'A'.repeat(40)}, {listId:'bad id'}, {listId:'prod',publicKeyFile:'keys/key.asc',fingerprint:'123'}]) assert.throws(()=>validateConfig({...base,signedRelease}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('public certificate envelope rejects private material and unbounded input', () => {
|
||||||
|
for (const text of ['-----BEGIN PGP PRIVATE KEY BLOCK-----\nabc\n-----END PGP PRIVATE KEY BLOCK-----\n', 'x'.repeat(SIGNED_RELEASE_LIMITS.publicKeyBytes+1), '-----BEGIN PGP PUBLIC KEY BLOCK-----\nabc\n-----END PGP PUBLIC KEY BLOCK-----\n-----BEGIN PGP PUBLIC KEY BLOCK-----\nx\n-----END PGP PUBLIC KEY BLOCK-----\n']) assert.throws(()=>validatePublicCertificate(text));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function buildFixture(config, action) {
|
||||||
|
const directory=await mkdtemp(join(tmpdir(),'conf-signed-build-'));
|
||||||
|
try {
|
||||||
|
await cp(join(root,'site'),join(directory,'site'),{recursive:true});await mkdir(join(directory,'keys'));
|
||||||
|
await cp(join(root,'conf-sshd.sh'),join(directory,'conf-sshd.sh'));
|
||||||
|
await writeFile(join(directory,'deployment.json'),JSON.stringify({baseUrl:'https://ssh.example.com',repositoryUrl:'https://github.com/example/repo',defaultCron:'*/15 * * * *',allowEmpty:false,...config}));
|
||||||
|
return await action(directory,()=>build({directory,revision:'d'.repeat(40),publishedAt:'2026-10-13T00:00:00.000Z'}));
|
||||||
|
} finally {await rm(directory,{recursive:true,force:true});}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Git build publishes exact detached files as Worker Static Assets and generated bytes', async () => {
|
||||||
|
await buildFixture({},async (dir,buildNow)=>{
|
||||||
|
await writeFile(join(dir,'keys/ssh.keys'),document);await writeFile(join(dir,'keys/ssh.keys.sig'),signature);
|
||||||
|
await writeFile(join(dir,'keys/authorized_keys'),'# ordinary repository file deliberately empty\n');
|
||||||
|
const result=await buildNow();assert.equal(result.keyCount,1);assert.equal(result.signedDocument,document);
|
||||||
|
assert.equal(result.signature,signature);assert.equal(result.signatureAvailable,true);
|
||||||
|
assert.equal(await readFile(join(dir,'cf-worker/public/ssh.keys'),'utf8'),document);
|
||||||
|
assert.equal(await readFile(join(dir,'cf-worker/public/ssh.keys.sig'),'utf8'),signature);
|
||||||
|
assert.equal(result.sha256,createHash('sha256').update(canonical).digest('hex'));
|
||||||
|
const manifest=JSON.parse(await readFile(join(dir,'cf-worker/generated/manifest.json'),'utf8'));
|
||||||
|
assert.equal(manifest.signedMetadata.sequence,1);assert.equal(manifest.signedDocument,undefined);
|
||||||
|
assert.equal(manifest.signature,undefined);assert.equal(manifest.publicCertificate,undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Git missing/malformed signature is usable with OFF; bundle and detached files cannot conflict', async () => {
|
||||||
|
await buildFixture({},async (dir,buildNow)=>{
|
||||||
|
await writeFile(join(dir,'keys/ssh.keys'),document);
|
||||||
|
for(const content of [null,'bad']){
|
||||||
|
if(content!==null)await writeFile(join(dir,'keys/ssh.keys.sig'),content);
|
||||||
|
const result=await buildNow();assert.equal(result.signedDocument,document);assert.equal(result.signature,null);
|
||||||
|
await assert.rejects(readFile(join(dir,'cf-worker/public/ssh.keys.sig')),{code:'ENOENT'});
|
||||||
|
}
|
||||||
|
await writeFile(join(dir,'keys/release.bundle'),document+SIGNATURE_SEPARATOR+signature);
|
||||||
|
await assert.rejects(buildNow(),/not both/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Git revocation bundle overrides legacy empty policy without falling back', async () => {
|
||||||
|
await buildFixture({fallbackSources:['https://keys.example.com/fallback']},async (dir,buildNow)=>{
|
||||||
|
const revoke=await makeSignedDocument('',{...policy,revoke:true});
|
||||||
|
await writeFile(join(dir,'keys/release.bundle'),revoke+SIGNATURE_SEPARATOR+signature);
|
||||||
|
const result=await buildNow();assert.equal(result.allowEmpty,true);assert.equal(result.keyCount,0);assert.equal(result.needsFallback,false);
|
||||||
|
assert.equal(await readFile(join(dir,'cf-worker/public/ssh.keys'),'utf8'),revoke);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dynamic Gist builds need no local keys and must not publish stale static key placeholders', async () => {
|
||||||
|
await buildFixture({keySource:{type:'gist',id:'a'.repeat(32),ownerId:7,ownerLogin:'example'}},async (dir,buildNow)=>{
|
||||||
|
await writeFile(join(dir,'keys/authorized_keys'),fixture);
|
||||||
|
const result=await buildNow();assert.equal(result.keys.length,0);assert.equal(result.needsSource,true);assert.equal(result.sha256,null);
|
||||||
|
await assert.rejects(readFile(join(dir,'cf-worker/public/ssh.keys')),{code:'ENOENT'});
|
||||||
|
assert.doesNotMatch(await readFile(join(dir,'cf-worker/generated/release.js'),'utf8'),/TEST-ONLY/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ON static defaults fail known-missing signatures and mismatched list IDs', async () => {
|
||||||
|
await buildFixture({clientDefaults:{gpgVerification:'on',fingerprint:'A'.repeat(40),listId:'production'}},async (dir,buildNow)=>{
|
||||||
|
await writeFile(join(dir,'keys/authorized_keys'),fixture);
|
||||||
|
await assert.rejects(buildNow(),/require a signed document/);
|
||||||
|
await writeFile(join(dir,'keys/ssh.keys'),document);await writeFile(join(dir,'keys/ssh.keys.sig'),signature);
|
||||||
|
assert.equal((await buildNow()).clientDefaults.gpgVerification,'on');
|
||||||
|
await writeFile(join(dir,'keys/ssh.keys'),document.replace('list-id production','list-id wrong'));
|
||||||
|
await assert.rejects(buildNow(),/list ID differs/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('real detached signatures verify with a public-only isolated GPG keyring and reject tampering/pin mismatch', async () => {
|
||||||
|
const fixture = openPgpFixture({subkey:true});
|
||||||
|
const actualSignature=fixture.sign(document), bundle=document+SIGNATURE_SEPARATOR+actualSignature;
|
||||||
|
assert.equal(await checkSigningCertificate(fixture.publicKey,fixture.primaryFingerprint,bundle),fixture.primaryFingerprint);
|
||||||
|
await assert.rejects(checkSigningCertificate(fixture.publicKey,'A'.repeat(40),bundle),/fingerprint mismatch/);
|
||||||
|
await assert.rejects(checkSigningCertificate(fixture.publicKey,fixture.primaryFingerprint,document.replace('sequence 1','sequence 2')+SIGNATURE_SEPARATOR+actualSignature),/verification failed/);
|
||||||
|
await assert.rejects(checkSigningCertificate(fixture.publicKey,fixture.primaryFingerprint,document+SIGNATURE_SEPARATOR+signature),/verification failed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('real revoked and expired signing identities fail even for formerly valid payload signatures', async () => {
|
||||||
|
for(const options of [{expired:true},{revoked:true}]) {
|
||||||
|
const fixture=openPgpFixture(options);
|
||||||
|
await assert.rejects(checkSigningCertificate(fixture.publicKey,fixture.primaryFingerprint,document+SIGNATURE_SEPARATOR+fixture.sign(document)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strict GPG status requires binary v4 strong hash and rejects every failure marker', () => {
|
||||||
|
const fp='A'.repeat(40), status=`[GNUPG:] VALIDSIG ${fp} 2026-01-01 100 0 4 0 1 8 00 ${fp}\n`;
|
||||||
|
assert.equal(validatePayloadSignatureStatus(status,fp).primaryFingerprint,fp);
|
||||||
|
for(const token of ['BADSIG','ERRSIG','NO_PUBKEY','EXPSIG','EXPKEYSIG','REVKEYSIG','KEYEXPIRED','SIGEXPIRED','KEYREVOKED','FAILURE','ERROR','NODATA','BADARMOR']) assert.throws(()=>validatePayloadSignatureStatus(status+`[GNUPG:] ${token} value\n`,fp));
|
||||||
|
for(const invalid of [status+status,status.replace('1 8 00','1 11 00'),status.replace('1 8 00','1 2 00'),status.replace('1 8 00','1 8 01'),status.replace('0 4 0','0 5 0'),status.trim().split(' ').slice(0,-1).join(' '),status.replace('100 0 4','100 101 4')]) assert.throws(()=>validatePayloadSignatureStatus(invalid,fp));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('build publishes only a validated public certificate and verifies exact signed bytes against its primary pin', async () => {
|
||||||
|
const fixture=openPgpFixture({subkey:true});
|
||||||
|
await buildFixture({signedRelease:{listId:'production',publicKeyFile:'keys/certificate.asc',fingerprint:fixture.primaryFingerprint}},async (dir,buildNow)=>{
|
||||||
|
await writeFile(join(dir,'keys/certificate.asc'),fixture.publicKey);
|
||||||
|
await writeFile(join(dir,'keys/ssh.keys'),document);await writeFile(join(dir,'keys/ssh.keys.sig'),fixture.sign(document));
|
||||||
|
const release=await buildNow();assert.equal(release.publicCertificate,fixture.publicKey);
|
||||||
|
assert.equal(await readFile(join(dir,'cf-worker/public/public-key.gpg'),'utf8'),fixture.publicKey);
|
||||||
|
await writeFile(join(dir,'keys/ssh.keys'),document.replace('sequence 1','sequence 2'));
|
||||||
|
await assert.rejects(buildNow(),/verification failed/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remote verification ON cannot pass a dynamic Gist with a missing detached signature', async () => {
|
||||||
|
const revision='a'.repeat(40), gist={type:'gist',id:'b'.repeat(32),ownerId:1,ownerLogin:'user'}, sha256=createHash('sha256').update(canonical).digest('hex');
|
||||||
|
const manifest={revision,baseUrl:'https://ssh.example.com',keySource:gist,needsSource:true,fallbackSources:[],clientDefaults:{gpgVerification:'on'}};
|
||||||
|
const fetcher=async url=>new Response(url.endsWith('/release.json')?JSON.stringify({revision,sha256,keyCount:1,source:{...gist,gistRevision:'c'.repeat(40)}}):url.endsWith('/ssh.keys')?document:'',{status:url.endsWith('/ssh.keys.sig')?404:200});
|
||||||
|
await assert.rejects(verifyRemote(manifest,fetcher),/signature is missing/);
|
||||||
|
const text=await verifyRemote({...manifest,clientDefaults:{gpgVerification:'off'}},fetcher);
|
||||||
|
assert.match(text,/OpenPGP not checked/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('cryptographically valid text-mode signatures are rejected by exact-byte payload policy',async()=>{
|
||||||
|
const fixture=openPgpFixture();
|
||||||
|
const textSignature=fixture.signText(document);
|
||||||
|
const dir=await mkdtemp(join(tmpdir(),'conf-text-signature-'));
|
||||||
|
try {
|
||||||
|
const cert=join(dir,'public.asc'),ring=join(dir,'public.gpg'),doc=join(dir,'document'),sig=join(dir,'document.sig');
|
||||||
|
await writeFile(cert,fixture.publicKey,{mode:0o600});
|
||||||
|
await writeFile(doc,document,{mode:0o600});
|
||||||
|
await writeFile(sig,textSignature,{mode:0o600});
|
||||||
|
const args=['--no-options','--homedir',dir,'--batch','--no-tty','--no-autostart','--no-auto-key-retrieve','--auto-key-locate','clear'];
|
||||||
|
execFileSync('gpg',[...args,'--output',ring,'--dearmor',cert],{stdio:'pipe'});
|
||||||
|
const status=execFileSync('gpg',[...args,'--no-default-keyring','--keyring',ring,'--status-fd','1','--verify',sig,doc],{encoding:'utf8',stdio:['ignore','pipe','pipe']});
|
||||||
|
assert.match(status,/\[GNUPG:\] VALIDSIG .* 01 /);
|
||||||
|
await assert.rejects(checkSigningCertificate(fixture.publicKey,fixture.primaryFingerprint,document+SIGNATURE_SEPARATOR+textSignature),/binary/);
|
||||||
|
} finally {await rm(dir,{recursive:true,force:true});}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('list identity dash is reserved consistently with local enrollment state',async()=>{
|
||||||
|
await assert.rejects(makeSignedDocument(fixture,{...policy,listId:'-'}),/list ID/);
|
||||||
|
await assert.rejects(parseSignedDocument(document.replace('# list-id production','# list-id -'),{checkFreshness:false}),/list ID/);
|
||||||
|
assert.throws(()=>validateConfig({baseUrl:'https://ssh.example.com',repositoryUrl:'https://github.com/test/repo',defaultCron:'*/15 * * * *',allowEmpty:false,signedRelease:{listId:'-'}}),/list ID/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
import vm from "node:vm";
|
||||||
|
import { commandsFor, pathsFor, renderPage } from "../cf-worker/src/view.js";
|
||||||
|
import { makeSignedDocument, parsePublishedSignedRelease } from "../cf-worker/src/signed-release.js";
|
||||||
|
|
||||||
|
const script = await readFile(new URL("../site/assets/app.js", import.meta.url), "utf8");
|
||||||
|
const html = await readFile(new URL("../site/index.html", import.meta.url), "utf8");
|
||||||
|
const css = await readFile(new URL("../site/assets/style.css", import.meta.url), "utf8");
|
||||||
|
const config = { baseUrl: "https://example.test", repositoryUrl: "https://github.com/example/repo", defaultCron: "*/15 * * * *" };
|
||||||
|
const commands = commandsFor(config);
|
||||||
|
|
||||||
|
class Element {
|
||||||
|
constructor(id, text = "") {
|
||||||
|
this.id = id;
|
||||||
|
this.textContent = text;
|
||||||
|
this.dataset = {};
|
||||||
|
this.attributes = new Map();
|
||||||
|
this.listeners = new Map();
|
||||||
|
this.classList = { add: (name) => this.attributes.set("class", name) };
|
||||||
|
this.disabled = false;
|
||||||
|
this.open = false;
|
||||||
|
this.value = "";
|
||||||
|
this.focused = false;
|
||||||
|
this.selected = false;
|
||||||
|
}
|
||||||
|
setAttribute(name, value) { this.attributes.set(name, value); }
|
||||||
|
getAttribute(name) { return this.attributes.get(name) ?? null; }
|
||||||
|
removeAttribute(name) { this.attributes.delete(name); }
|
||||||
|
addEventListener(name, handler) { this.listeners.set(name, handler); }
|
||||||
|
dispatch(name) { return this.listeners.get(name)?.({ target: this }); }
|
||||||
|
focus() { this.focused = true; }
|
||||||
|
select() { this.selected = true; }
|
||||||
|
showModal() { this.open = true; }
|
||||||
|
close() { this.open = false; this.dispatch("close"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup({ commandTexts = commands.map(({command}) => command), savedTheme, brokenStorage = false, clipboard = async () => {}, missingTarget = false } = {}) {
|
||||||
|
const root = new Element("root");
|
||||||
|
const ids = ["theme-picker", "copy-status", "copy-fallback", "fallback-text", "close-copy-fallback"];
|
||||||
|
const elements = new Map(ids.map((id) => [id, new Element(id)]));
|
||||||
|
const codeElements = commandTexts.map((text, index) => new Element(`command-${index}`, text));
|
||||||
|
const pres = codeElements.map((code) => new Element(`pre-${code.id}`));
|
||||||
|
const buttons = codeElements.map((code) => {
|
||||||
|
const button = new Element(`copy-${code.id}`);
|
||||||
|
button.dataset.copyTarget = code.id;
|
||||||
|
button.setAttribute("aria-label", `复制 ${code.id}`);
|
||||||
|
return button;
|
||||||
|
});
|
||||||
|
for (const element of codeElements) elements.set(element.id, element);
|
||||||
|
if (missingTarget) elements.delete(codeElements[0].id);
|
||||||
|
const requestedIds = [];
|
||||||
|
const document = {
|
||||||
|
documentElement: root,
|
||||||
|
getElementById: (id) => { requestedIds.push(id); return elements.get(id) || null; },
|
||||||
|
querySelectorAll: (selector) => ({ ".code-block pre": pres, "[data-copy-target]": buttons })[selector] || [],
|
||||||
|
};
|
||||||
|
const stored = new Map(savedTheme ? [["quickly-conf-sshd-theme", savedTheme]] : []);
|
||||||
|
const timers = new Map();
|
||||||
|
let timerId = 0;
|
||||||
|
const context = vm.createContext({
|
||||||
|
document,
|
||||||
|
navigator: clipboard === null ? {} : { clipboard: { writeText: clipboard } },
|
||||||
|
localStorage: {
|
||||||
|
getItem: (key) => { if (brokenStorage) throw new Error("Denied"); return stored.get(key) || null; },
|
||||||
|
setItem: (key, value) => { if (brokenStorage) throw new Error("Denied"); stored.set(key, value); },
|
||||||
|
},
|
||||||
|
setTimeout: (callback) => { timers.set(++timerId, callback); return timerId; },
|
||||||
|
clearTimeout: (id) => timers.delete(id),
|
||||||
|
});
|
||||||
|
vm.runInContext(script, context);
|
||||||
|
return { root, elements, codeElements, buttons, pres, stored, requestedIds, flushTimers: () => {
|
||||||
|
const pending = [...timers.values()];
|
||||||
|
timers.clear();
|
||||||
|
pending.forEach((callback) => callback());
|
||||||
|
} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(overrides = {}) {
|
||||||
|
return {revision: "a".repeat(40), publishedAt: "2026-10-12T00:00:00Z", keys: [], keyCount: 1, sha256: "b".repeat(64), keyRevision: "c".repeat(64), commands, ...config, signedRelease: {listId: "test-list"}, ...overrides};
|
||||||
|
}
|
||||||
|
function embeddedMetadata(page) {
|
||||||
|
return JSON.parse(page.match(/<script id="release-data" type="application\/json">([\s\S]*?)<\/script>/)[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("homepage prerenders metadata and commands, never a public-key listing", () => {
|
||||||
|
for (const token of ["RELEASE_JSON", "COMMANDS_HTML", "PATHS_HTML", "REVISION", "PUBLISHED_AT", "KEY_COUNT", "REPOSITORY_URL", "RELEASE_LABEL", "PREVIEW_NOTICE", "SOURCE_LABEL", "SOURCE_STATUS", "SIGNATURE_STATUS", "CERTIFICATE_STATUS", "CERTIFICATE_LINK", "SIGNATURE_SECTION_START", "SIGNATURE_SECTION_END", "VERIFICATION_DEFAULT", "PATHS_INDEX", "CONTENT_REVISION", "KEYS_NOTICE"]) {
|
||||||
|
assert.ok(html.includes(`@@${token}@@`), `${token} placeholder is required`);
|
||||||
|
}
|
||||||
|
assert.match(html, /<html lang="zh-CN">/);
|
||||||
|
assert.match(html, /href="\/assets\/style\.css"/);
|
||||||
|
assert.match(html, /src="\/assets\/app\.js" defer/);
|
||||||
|
assert.match(html, /<script id="release-data" type="application\/json">/);
|
||||||
|
assert.match(html, /aria-live="polite"/);
|
||||||
|
assert.match(html, /<noscript>/);
|
||||||
|
assert.match(html, /不代表所有服务器均已同步/);
|
||||||
|
assert.match(html, /不会要求上传或输入私钥/);
|
||||||
|
assert.match(html, /公钥不是秘密/);
|
||||||
|
assert.match(html, /原始 API 与公开 Git \/ Gist/);
|
||||||
|
assert.doesNotMatch(html, /KEYS_HTML|key-list|key-card|copy-all|github\.io|https?:[^"']+\.(?:css|js)["']/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("homepage projection excludes raw keys, fingerprints, comments and signed document content", () => {
|
||||||
|
const secretMarker = "must-not-appear-in-homepage";
|
||||||
|
const key = {text: `ssh-ed25519 ${secretMarker} comment`, fingerprint: `SHA256:${secretMarker}`, label: `${secretMarker}<script>`, type: "ssh-ed25519"};
|
||||||
|
const page = renderPage(html, snapshot({keys: [key], keysText: key.text, signedDocument: key.text, signature: `-----BEGIN PGP SIGNATURE-----\nComment: ${secretMarker}\n\nAQID\n-----END PGP SIGNATURE-----\n`, publicCertificate: secretMarker, signedBundle: secretMarker, signedMetadata: {listId: secretMarker}, source: {type: "gist", url: "https://gist.github.com/example/example", keys: [key], fingerprint: key.fingerprint}}));
|
||||||
|
assert.doesNotMatch(page, new RegExp(secretMarker));
|
||||||
|
assert.doesNotMatch(page, /ssh-ed25519|SHA256:|data-copy-target="key-|id="key-\d|class="key-card"/);
|
||||||
|
const embedded = embeddedMetadata(page);
|
||||||
|
assert.deepEqual(Object.keys(embedded).sort(), ["schema", "revision", "publishedAt", "keyCount", "sha256", "keyRevision", "source", "sourceStatus", "signatureEnabled", "signaturePresent", "signatureStatus", "certificatePresent", "certificateStatus", "repositoryUrl", "testFixture"].sort());
|
||||||
|
assert.deepEqual(embedded.source, {type: "gist", url: "https://gist.github.com/example/example"});
|
||||||
|
assert.equal(embedded.keyCount, 1);
|
||||||
|
assert.equal(embedded.keyRevision, "c".repeat(64));
|
||||||
|
assert.equal(embedded.signaturePresent, true);
|
||||||
|
assert.match(embedded.signatureStatus, /待客户端验证/);
|
||||||
|
assert.doesNotMatch(embedded.signatureStatus, /已验签|已验证签名|verified/i);
|
||||||
|
assert.match(page, /不代表 Worker 已验证签名/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("source states and signature presence describe one snapshot without claiming verification", () => {
|
||||||
|
for (const [type, status] of [["repository", "可用"], ["git", "可用"], ["gist", "可用"], ["fallback", "备用源可用"], ["unavailable", "暂不可用"], ["pending", "待获取"], ["explicit-empty", "明确撤销"]]) {
|
||||||
|
const unavailable = ["unavailable", "pending"].includes(type);
|
||||||
|
const empty = unavailable || type === "explicit-empty";
|
||||||
|
const state = snapshot({source: {type, url: 'https://example.test/list?text=<script>window.__injected=true</script>'}, keyCount: empty ? 0 : 1, allowEmpty: type === "explicit-empty", sha256: empty ? null : "b".repeat(64), keyRevision: empty ? null : "c".repeat(64)});
|
||||||
|
const page = renderPage(html, state);
|
||||||
|
const embedded = embeddedMetadata(page);
|
||||||
|
assert.equal(embedded.sourceStatus, status);
|
||||||
|
assert.equal(embedded.keyCount, empty ? 0 : 1);
|
||||||
|
assert.equal(embedded.signaturePresent, false);
|
||||||
|
assert.equal(embedded.signatureStatus, "未提供签名包");
|
||||||
|
assert.doesNotMatch(page, /<script>window\.__injected/);
|
||||||
|
if (unavailable) assert.match(page, /未发布撤销指令.*保留上次成功密钥/);
|
||||||
|
if (type === "explicit-empty") assert.match(page, /明确撤销全部受管理公钥/);
|
||||||
|
}
|
||||||
|
assert.equal(embeddedMetadata(renderPage(html, snapshot({signature: "unvalidated signature alone"}))).signaturePresent, false);
|
||||||
|
assert.match(renderPage(html, snapshot({source: {type: "gist"}, keyCount: 0, allowEmpty: true})), /class="source-status">明确撤销/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a parsed valid document with missing or malformed signature stays unsigned on the homepage", async () => {
|
||||||
|
const fixture = await readFile(new URL("./fixtures/keys.pub", import.meta.url), "utf8");
|
||||||
|
const document = await makeSignedDocument(fixture, {listId: "homepage-regression", sequence: 1, issuedAt: 1, expiresAt: 2});
|
||||||
|
for (const signature of [null, "malformed-detached-signature"]) {
|
||||||
|
const parsed = await parsePublishedSignedRelease(document, signature, {checkFreshness: false});
|
||||||
|
assert.ok(parsed.metadata);
|
||||||
|
assert.equal(parsed.signature, null);
|
||||||
|
const page = renderPage(html, snapshot({keys: parsed.keys, keysText: parsed.document, signedDocument: parsed.document, signedMetadata: parsed.metadata, signature: parsed.signature, signedBundle: parsed.bundle}));
|
||||||
|
const metadata = embeddedMetadata(page);
|
||||||
|
assert.equal(metadata.signaturePresent, false);
|
||||||
|
assert.equal(metadata.signatureStatus, "未提供签名包");
|
||||||
|
assert.equal(metadata.certificatePresent, false);
|
||||||
|
assert.ok(!page.includes(parsed.keys[0].text));
|
||||||
|
assert.ok(!page.includes(document));
|
||||||
|
assert.doesNotMatch(page, /malformed-detached-signature|conf-sshd-signed-v1/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("signature and certificate availability are independent and never expose their bytes", () => {
|
||||||
|
const document = "document-bytes-must-not-appear";
|
||||||
|
const signature = "-----BEGIN PGP SIGNATURE-----\nComment: signature-bytes-must-not-appear\n\nAQID\n-----END PGP SIGNATURE-----\n";
|
||||||
|
const certificate = "-----BEGIN PGP PUBLIC KEY BLOCK-----\ncertificate-bytes-must-not-appear\n-----END PGP PUBLIC KEY BLOCK-----\n";
|
||||||
|
const paired = {keysText: document, signedDocument: document, signedMetadata: {listId: "test-list"}};
|
||||||
|
const cases = [
|
||||||
|
{name: "document alone", data: paired, signature: false, certificate: false},
|
||||||
|
{name: "malformed detached signature", data: {...paired, signature: "malformed-signature-must-not-appear"}, signature: false, certificate: false},
|
||||||
|
{name: "valid pair without certificate", data: {...paired, signature}, signature: true, certificate: false},
|
||||||
|
{name: "certificate without signature", data: {...paired, publicCertificate: certificate}, signature: false, certificate: true},
|
||||||
|
{name: "valid pair with certificate", data: {...paired, signature, publicCertificate: certificate}, signature: true, certificate: true},
|
||||||
|
{name: "wrong document pair", data: {...paired, keysText: "different-document", signature}, signature: false, certificate: false},
|
||||||
|
{name: "explicit unavailable flags", data: {...paired, signature, publicCertificate: certificate, signatureAvailable: false, publicCertificateAvailable: false}, signature: false, certificate: false},
|
||||||
|
{name: "explicit available flags", data: {signatureAvailable: true, publicCertificateAvailable: true}, signature: true, certificate: true},
|
||||||
|
{name: "unavailable keys retain independent certificate", data: {...paired, source: {type: "unavailable"}, signatureAvailable: true, publicCertificate: certificate}, signature: false, certificate: true},
|
||||||
|
];
|
||||||
|
for (const entry of cases) {
|
||||||
|
const page = renderPage(html, snapshot(entry.data));
|
||||||
|
const metadata = embeddedMetadata(page);
|
||||||
|
assert.equal(metadata.signaturePresent, entry.signature, entry.name);
|
||||||
|
assert.equal(metadata.certificatePresent, entry.certificate, entry.name);
|
||||||
|
assert.equal(metadata.signatureStatus, entry.signature ? "签名包已提供(待客户端验证)" : "未提供签名包", entry.name);
|
||||||
|
assert.equal(metadata.certificateStatus, entry.certificate ? "公开证书已提供(安装时核验)" : "未提供公开证书", entry.name);
|
||||||
|
assert.equal(page.includes('href="/public-key.gpg"'), entry.certificate, entry.name);
|
||||||
|
assert.doesNotMatch(page, /must-not-appear|BEGIN PGP|AQID/, entry.name);
|
||||||
|
assert.equal(metadata.signedMetadata, undefined);
|
||||||
|
assert.equal(metadata.signature, undefined);
|
||||||
|
assert.equal(metadata.publicCertificate, undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("commands use an explicit installer and fixed installed client; no self-update command", () => {
|
||||||
|
const byId = Object.fromEntries(commands.map((command) => [command.id, command]));
|
||||||
|
assert.equal(byId.install.command, "curl -fsSL 'https://example.test/script.sh' | sh -s -- --install --download-script -c");
|
||||||
|
assert.equal(byId.once.command, 'sh "$HOME/.conf-sshd/conf-sshd.sh" -o');
|
||||||
|
assert.equal(byId.preview.command, 'sh "$HOME/.conf-sshd/conf-sshd.sh" -o --dry-run');
|
||||||
|
assert.equal(byId.help.command, "curl -fsSL 'https://example.test/script.sh' | sh -s -- -h");
|
||||||
|
assert.match(byId.help.description, /不建立信任/);
|
||||||
|
assert.match(byId.install.description, /备份.*保留本地配置/);
|
||||||
|
assert.match(byId.reinstall.description, /首次安装或明确重装都会信任你执行的脚本/);
|
||||||
|
assert.match(byId.reinstall.description, /推荐先独立核验安装脚本/);
|
||||||
|
assert.doesNotMatch(byId.install.description, /重装须使用.*本地脚本/);
|
||||||
|
assert.deepEqual(commands.filter(({advanced}) => !advanced).map(({id}) => id), ["install", "deploy-once", "keys-only", "root-on", "help"]);
|
||||||
|
assert.equal(byId["deploy-once"].command, "curl -fsSL 'https://example.test/script.sh' | sh -s --");
|
||||||
|
assert.equal(byId["keys-only"].command, "curl -fsSL 'https://example.test/script.sh' | sh -s -- -o");
|
||||||
|
assert.equal(byId["root-on"].command, "curl -fsSL 'https://example.test/script.sh' | sh -s -- -p yes");
|
||||||
|
assert.equal(byId["skip-sshd-install"].command, "curl -fsSL 'https://example.test/script.sh' | sh -s -- -n");
|
||||||
|
assert.equal(byId["cron-custom"].command, 'sh "$HOME/.conf-sshd/conf-sshd.sh" -c \'0 */6 * * *\'');
|
||||||
|
assert.equal(byId["cron-stop"].command, 'sh "$HOME/.conf-sshd/conf-sshd.sh" -c false');
|
||||||
|
assert.equal(byId.status.command, 'sh "$HOME/.conf-sshd/conf-sshd.sh" --status');
|
||||||
|
assert.equal(byId.reinstall.command, "curl -fsSL 'https://example.test/script.sh' | sh -s -- --install --download-script");
|
||||||
|
assert.match(byId.install.description, /root 可按需安装 SSH Server 与 cron,非 root 缺少依赖时退出/);
|
||||||
|
assert.match(byId["deploy-once"].description, /不新增定时任务/);
|
||||||
|
assert.match(byId["keys-only"].description, /跳过 SSH Server 检查与软件包配置/);
|
||||||
|
assert.match(byId["root-on"].description, /须以 root 执行.*并部署公钥.*不自动重载/);
|
||||||
|
assert.match(byId.reinstall.description, /代替原 -u/);
|
||||||
|
assert.equal(byId.update, undefined);
|
||||||
|
for (const id of ["root-off", "uninstall"]) {
|
||||||
|
assert.equal(byId[id].advanced, true);
|
||||||
|
assert.ok(byId[id].command.startsWith('sh "$HOME/.conf-sshd/conf-sshd.sh"'));
|
||||||
|
}
|
||||||
|
assert.ok(commands.every(({command}) => !/(?:^|\s)-u(?:\s|$)|--update/.test(command)));
|
||||||
|
assert.match(html, /预期 SHA-256.*pin/);
|
||||||
|
assert.match(renderPage(html, snapshot()), /验签默认关闭/);
|
||||||
|
assert.match(html, /--install --gpg-verification on/);
|
||||||
|
assert.match(html, /远端发布内容不能关闭本地验签/);
|
||||||
|
assert.match(html, /本地终端确认完整指纹,不能跳过确认/);
|
||||||
|
assert.match(html, /日常同步不联网刷新证书/);
|
||||||
|
assert.doesNotMatch(html, /--confirm-trust/);
|
||||||
|
assert.match(html, /不请求签名/);
|
||||||
|
assert.match(html, /两者均返回 200/);
|
||||||
|
assert.match(html, /href="\/ssh\.keys\.sig"/);
|
||||||
|
assert.doesNotMatch(html, /\/key-release/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Quick Usage and Paths retain original categories without repository setup instructions", () => {
|
||||||
|
assert.match(html, /Quick Usage · 快速使用/);
|
||||||
|
assert.match(html, /Paths · 访问地址/);
|
||||||
|
assert.doesNotMatch(html, /<h[1-6][^>]*>Install|Fork 本仓库|wrangler publish/);
|
||||||
|
const paths = pathsFor({...config, signedRelease: {listId: "test-list"}});
|
||||||
|
assert.deepEqual(paths.slice(0, 4).map(({id, url}) => ({id, url})), [
|
||||||
|
{id: "keys", url: "https://example.test/ssh.keys"},
|
||||||
|
{id: "script", url: "https://example.test/script.sh"},
|
||||||
|
{id: "root-script", url: "https://example.test/"},
|
||||||
|
{id: "homepage", url: "https://example.test/"},
|
||||||
|
]);
|
||||||
|
assert.match(paths.find(({id}) => id === "root-script").description, /Curl.*Shell.*text\/html/);
|
||||||
|
assert.equal(paths.find(({id}) => id === "signature").url, "https://example.test/ssh.keys.sig");
|
||||||
|
assert.equal(paths.find(({id}) => id === "certificate").url, "https://example.test/public-key.gpg");
|
||||||
|
const page = renderPage(html, snapshot());
|
||||||
|
for (const path of paths) {
|
||||||
|
assert.ok(page.includes(`id="path-${path.id}">${path.url}</code>`));
|
||||||
|
assert.ok(page.includes(`data-copy-target="path-${path.id}"`));
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(page, /copy-all-paths|copy-all-commands/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("section order and Signature visibility depend only on trusted signing configuration", () => {
|
||||||
|
for (const [state, enabled] of [
|
||||||
|
[{signedRelease: null, clientDefaults: {gpgVerification: "off"}}, false],
|
||||||
|
[{signedRelease: null, clientDefaults: {gpgVerification: "off"}, signatureAvailable: true, signedMetadata: {listId: "untrusted-metadata"}, publicCertificate: "opaque-cert"}, false],
|
||||||
|
[{signedRelease: {listId: "configured"}, clientDefaults: {gpgVerification: "off"}, signatureAvailable: false}, true],
|
||||||
|
[{signedRelease: null, clientDefaults: {gpgVerification: "on"}, signatureAvailable: false}, true],
|
||||||
|
]) {
|
||||||
|
const page = renderPage(html, snapshot(state));
|
||||||
|
const sections = [...page.matchAll(/<section id="([^"]+)" class="content-section/g)].map((match) => match[1]);
|
||||||
|
assert.deepEqual(sections, enabled ? ["quick-start", "key-status", "signature", "paths"] : ["quick-start", "key-status", "paths"]);
|
||||||
|
assert.equal(embeddedMetadata(page).signatureEnabled, enabled);
|
||||||
|
assert.ok(page.includes(`href="${config.repositoryUrl}#readme"`), "installation instructions remain linked even without Signature");
|
||||||
|
assert.equal(page.includes('id="path-signature"'), enabled);
|
||||||
|
assert.equal(page.includes('id="path-certificate"'), enabled);
|
||||||
|
assert.doesNotMatch(page, /@@[A-Z_]+@@/);
|
||||||
|
if (enabled) {
|
||||||
|
assert.match(page, /Signature · 签名与本地信任/);
|
||||||
|
assert.match(page, /未提供签名包/);
|
||||||
|
if (state.clientDefaults.gpgVerification === "on") assert.match(page, /本仓库默认开启客户端验签/);
|
||||||
|
else assert.match(page, /客户端验签默认关闭/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepEqual(pathsFor(config).map(({id}) => id), ["keys", "script", "root-script", "homepage"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("default follows system and explicit light/dark/system choices persist", () => {
|
||||||
|
const page = setup();
|
||||||
|
const select = page.elements.get("theme-picker");
|
||||||
|
assert.equal(select.value, "system");
|
||||||
|
assert.equal(page.root.getAttribute("data-theme"), null);
|
||||||
|
for (const theme of ["dark", "light", "system"]) {
|
||||||
|
select.value = theme;
|
||||||
|
select.dispatch("change");
|
||||||
|
assert.equal(page.root.getAttribute("data-theme"), theme === "system" ? null : theme);
|
||||||
|
assert.equal(page.stored.get("quickly-conf-sshd-theme"), theme);
|
||||||
|
}
|
||||||
|
assert.equal(setup({ savedTheme: "dark" }).root.getAttribute("data-theme"), "dark");
|
||||||
|
assert.equal(setup({ savedTheme: "unexpected" }).root.getAttribute("data-theme"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("storage restrictions do not prevent theme selection or enhancement", () => {
|
||||||
|
const page = setup({ brokenStorage: true });
|
||||||
|
const select = page.elements.get("theme-picker");
|
||||||
|
select.value = "dark";
|
||||||
|
select.dispatch("change");
|
||||||
|
assert.equal(page.root.getAttribute("data-theme"), "dark");
|
||||||
|
assert.equal(page.root.getAttribute("class"), "enhanced");
|
||||||
|
for (const pre of page.pres) assert.equal(pre.getAttribute("tabindex"), "0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("individual command copies retain every byte, including shell quotes and punctuation", async () => {
|
||||||
|
const exact = `sh "$HOME/${"long-path-".repeat(250)}/conf-sshd.sh" -o --dry-run`;
|
||||||
|
const copied = [];
|
||||||
|
const page = setup({ commandTexts: [commands[0].command, exact], clipboard: async (text) => copied.push(text) });
|
||||||
|
await page.buttons[0].dispatch("click");
|
||||||
|
await page.buttons[1].dispatch("click");
|
||||||
|
assert.deepEqual(copied, [commands[0].command, exact]);
|
||||||
|
assert.equal(page.buttons[0].dataset.state, "success");
|
||||||
|
assert.match(page.buttons[0].getAttribute("aria-label"), /已复制/);
|
||||||
|
assert.equal(page.buttons[0].disabled, false);
|
||||||
|
assert.equal(page.buttons[0].getAttribute("aria-busy"), null);
|
||||||
|
page.flushTimers();
|
||||||
|
assert.equal(page.elements.get("copy-status").textContent, "已复制完整内容。");
|
||||||
|
assert.equal(page.buttons[0].dataset.state, undefined);
|
||||||
|
assert.equal(page.buttons[0].getAttribute("aria-label"), "复制 command-0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("page enhancement neither requests key data nor reads embedded release metadata", () => {
|
||||||
|
const page = setup();
|
||||||
|
assert.ok(!page.requestedIds.includes("release-data"));
|
||||||
|
assert.ok(!page.requestedIds.includes("copy-all-keys"));
|
||||||
|
assert.doesNotMatch(script, /release-data|release\.keys|key-card|allKeysButton|JSON\.parse|\bfetch\s*\(|XMLHttpRequest|WebSocket|EventSource/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing command targets disable only their own copy buttons", async () => {
|
||||||
|
const copied = [];
|
||||||
|
const page = setup({missingTarget: true, clipboard: async (text) => copied.push(text)});
|
||||||
|
assert.equal(page.buttons[0].disabled, true);
|
||||||
|
await page.buttons[0].dispatch("click");
|
||||||
|
await page.buttons[1].dispatch("click");
|
||||||
|
assert.deepEqual(copied, [page.codeElements[1].textContent]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("denied or unavailable clipboard offers exact command for manual copy and returns focus", async () => {
|
||||||
|
for (const clipboard of [null, async () => { throw new Error("NotAllowedError"); }]) {
|
||||||
|
const exact = commands[0].command;
|
||||||
|
const page = setup({ commandTexts: [exact], clipboard });
|
||||||
|
await page.buttons[0].dispatch("click");
|
||||||
|
const dialog = page.elements.get("copy-fallback");
|
||||||
|
const textarea = page.elements.get("fallback-text");
|
||||||
|
assert.equal(dialog.open, true);
|
||||||
|
assert.equal(textarea.value, exact);
|
||||||
|
assert.equal(textarea.selected, true);
|
||||||
|
assert.equal(textarea.focused, true);
|
||||||
|
assert.equal(page.buttons[0].dataset.state, undefined);
|
||||||
|
assert.equal(page.buttons[0].disabled, false);
|
||||||
|
page.flushTimers();
|
||||||
|
assert.match(page.elements.get("copy-status").textContent, /手动复制/);
|
||||||
|
page.elements.get("close-copy-fallback").dispatch("click");
|
||||||
|
assert.equal(dialog.open, false);
|
||||||
|
assert.equal(page.buttons[0].focused, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repeated clicks while clipboard is pending make a single request and retries can recover", async () => {
|
||||||
|
let resolveCopy;
|
||||||
|
let calls = 0;
|
||||||
|
const page = setup({ clipboard: () => {
|
||||||
|
calls += 1;
|
||||||
|
return new Promise((resolve) => { resolveCopy = resolve; });
|
||||||
|
} });
|
||||||
|
const first = page.buttons[0].dispatch("click");
|
||||||
|
assert.equal(page.buttons[0].disabled, true);
|
||||||
|
await page.buttons[0].dispatch("click");
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
resolveCopy();
|
||||||
|
await first;
|
||||||
|
assert.equal(page.buttons[0].disabled, false);
|
||||||
|
const second = page.buttons[0].dispatch("click");
|
||||||
|
resolveCopy();
|
||||||
|
await second;
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
assert.equal(page.buttons[0].dataset.state, "success");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CSS covers system theme, narrow viewports, touch copies, focus, and reduced motion", () => {
|
||||||
|
for (const pattern of [/prefers-color-scheme: dark/, /:root\[data-theme="dark"\]/, /max-width: 680px/, /hover: hover/, /pointer: fine/, /focus-within/, /focus-visible/, /overflow-x: auto/, /prefers-reduced-motion/]) assert.match(css, pattern);
|
||||||
|
assert.doesNotMatch(script, /innerHTML|insertAdjacentHTML|\beval\s*\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("touch and narrow layouts explicitly override desktop copy-button hiding", () => {
|
||||||
|
const override = css.match(/@media \(hover: none\), \(pointer: coarse\), \(any-pointer: coarse\), \(max-width: 680px\) \{[\s\S]*?\n\}/);
|
||||||
|
assert.ok(override, "touch, hybrid, and narrow layouts need an explicit visibility rule");
|
||||||
|
assert.match(override[0], /\.code-block > \.copy-button \{ opacity: 1; transition: none; \}/);
|
||||||
|
assert.ok(override.index > css.indexOf("@media (hover: hover) and (pointer: fine)"), "touch visibility must win the CSS cascade");
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {createHandler} from '../cf-worker/src/router.js';
|
||||||
|
import {parsePublicKeys,makeKeyBundle} from '../cf-worker/src/key-source.js';
|
||||||
|
import {readFile} from 'node:fs/promises';
|
||||||
|
const fixture=await readFile(new URL('./fixtures/keys.pub',import.meta.url),'utf8');
|
||||||
|
const keys=await parsePublicKeys(fixture);
|
||||||
|
const revision='a'.repeat(40);
|
||||||
|
const bundle=await makeKeyBundle(keys,revision,false);
|
||||||
|
const release={revision,publishedAt:'2026-10-12',keyCount:1,allowEmpty:false,sha256:bundle.sha256,script:'#!/bin/sh\n',keysText:bundle.text,keys,fallbackSources:[],repositoryUrl:'https://github.com/test/repo',commands:[],pageTemplate:await readFile(new URL('../site/index.html',import.meta.url),'utf8')};
|
||||||
|
const fetch=createHandler(release);
|
||||||
|
const requests=[];
|
||||||
|
const env={ASSETS:{fetch:async request=>{requests.push(new URL(request.url).pathname);return new Response('asset:'+new URL(request.url).pathname,{headers:{'Content-Type':request.url.endsWith('.css')?'text/css':'text/html'}})}}};
|
||||||
|
const request=(path,init={})=>new Request('https://ssh.example.com'+path,init);
|
||||||
|
test('explicit endpoints ignore user agent and are never cached',async()=>{
|
||||||
|
for(const [path,body] of [['/script.sh',release.script],['/ssh.keys',release.keysText]]) {
|
||||||
|
const response=await fetch(request(path,{headers:{Accept:'text/html'}}),env);
|
||||||
|
assert.equal(await response.text(),body);assert.equal(response.headers.get('Cache-Control'),'no-store');assert.match(response.headers.get('Content-Type'),/^text\/plain/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('root serves browser HTML and legacy curl script without content mixing',async()=>{
|
||||||
|
for(const agent of ['curl/8.1.0','libcurl/8.1.0','Wget/1.21.3','HTTPie/3.2.4']){
|
||||||
|
const response=await fetch(request('/',{headers:{'User-Agent':agent,Accept:'*/*'}}),env);
|
||||||
|
assert.equal(await response.text(),release.script);assert.equal(response.headers.get('Vary'),'Accept, User-Agent');
|
||||||
|
}
|
||||||
|
const explicit=await fetch(request('/',{headers:{'User-Agent':'curl/8','Accept':'text/html'}}),env);
|
||||||
|
assert.match(await explicit.text(),/<html lang="zh-CN">/);
|
||||||
|
assert.match((await fetch(request('/'),env)).headers.get('Content-Type'),/^text\/html/);
|
||||||
|
});
|
||||||
|
test('metadata is current release; invalid methods and paths fail rather than returning HTML',async()=>{
|
||||||
|
const metadata=await (await fetch(request('/release.json'),env)).json();assert.equal(metadata.revision,release.revision);assert.equal(metadata.keyCount,1);
|
||||||
|
for(const path of ['/script.s','/ssh.key','/index.html','/assets/../../script.sh','/api/keys']) {
|
||||||
|
if(path==='/assets/../../script.sh') continue; // URL canonicalization is intentionally standard.
|
||||||
|
const response=await fetch(request(path),env);assert.equal(response.status,404);assert.match(response.headers.get('Content-Type'),/^text\/plain/);
|
||||||
|
}
|
||||||
|
const post=await fetch(request('/ssh.keys',{method:'POST'}),env);assert.equal(post.status,405);assert.equal(post.headers.get('Allow'),'GET, HEAD');
|
||||||
|
});
|
||||||
|
test('HEAD returns headers without body and missing page fails with 503',async()=>{
|
||||||
|
for(const path of ['/','/script.sh','/ssh.keys','/release.json']) assert.equal(await (await fetch(request(path,{method:'HEAD'}),env)).text(),'');
|
||||||
|
assert.equal((await createHandler({...release,pageTemplate:null})(request('/'),env)).status,503);
|
||||||
|
});
|
||||||
|
test('fingerprinted assets cached while security headers retained',async()=>{
|
||||||
|
const response=await fetch(request('/assets/app.012345abcdef.js'),env);assert.match(response.headers.get('Cache-Control'),/immutable/);assert.equal(response.headers.get('X-Content-Type-Options'),'nosniff');
|
||||||
|
const missing=await fetch(request('/assets/missing.js'),{ASSETS:{fetch:async()=>new Response('missing',{status:404})}});assert.equal(missing.status,404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fallback page and key metadata represent one effective snapshot; unavailable does not revoke',async()=>{
|
||||||
|
const fallback={...release,keys:[],keyCount:0,sha256:null,keysText:null,fallbackSources:['https://keys.example.com/server.keys']};
|
||||||
|
const handler=createHandler(fallback,{fetchImpl:async()=>new Response(fixture)});
|
||||||
|
const html=await (await handler(request('/'),env)).text();
|
||||||
|
assert.ok(html.includes('备用源可用'));assert.ok(html.includes('https://keys.example.com/server.keys'));assert.ok(!html.includes(keys[0].fingerprint));assert.ok(!html.includes(keys[0].text));
|
||||||
|
const match=html.match(/<script id="release-data" type="application\/json">([\s\S]*?)<\/script>/);
|
||||||
|
const embedded=JSON.parse(match[1]);assert.equal(embedded.sha256,bundle.sha256);assert.equal(embedded.keyCount,1);assert.equal(embedded.keys,undefined);
|
||||||
|
const metadata=await (await handler(request('/release.json'),env)).json();assert.equal(metadata.keyRevision,bundle.sha256);assert.equal(metadata.source.type,'fallback');
|
||||||
|
assert.equal(await (await handler(request('/ssh.keys'),env)).text(),bundle.text);
|
||||||
|
const unavailable=createHandler(fallback,{fetchImpl:async()=>new Response('',{status:503})});
|
||||||
|
for(const path of ['/ssh.keys','/release.json','/'])assert.equal((await unavailable(request(path),env)).status,503);
|
||||||
|
const failureHTML=await (await unavailable(request('/'),env)).text();assert.ok(failureHTML.includes('未发布撤销指令'));assert.ok(!failureHTML.includes('此版本明确撤销全部受管理公钥'));
|
||||||
|
assert.equal((await unavailable(request('/script.sh'),env)).status,200);
|
||||||
|
assert.equal(await (await unavailable(request('/',{headers:{'User-Agent':'curl/8'}}),env)).text(),release.script);
|
||||||
|
});
|
||||||
|
test('explicit empty revocation suppresses fallback in page, metadata and keys',async()=>{
|
||||||
|
const handler=createHandler({...release,keys:[],keyCount:0,allowEmpty:true,fallbackSources:['https://keys.example.com/server.keys']},{fetchImpl:async()=>{throw Error('must not fetch')}});
|
||||||
|
const html=await (await handler(request('/'),env)).text();assert.ok(html.includes('明确撤销'));assert.ok(!html.includes('未发布撤销指令'));
|
||||||
|
const keysText=await (await handler(request('/ssh.keys'),env)).text();assert.match(keysText,/# key-count 0\n# allow-empty yes/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signature endpoint never invents a signature for unsigned sources',async()=>{
|
||||||
|
for(const method of ['GET','HEAD']) {
|
||||||
|
const response=await fetch(request('/ssh.keys.sig',{method}),env);
|
||||||
|
assert.equal(response.status,404);
|
||||||
|
assert.equal(response.headers.get('Cache-Control'),'no-store');
|
||||||
|
if(method==='HEAD') assert.equal(await response.text(),'');
|
||||||
|
}
|
||||||
|
const metadata=await(await fetch(request('/release.json'),env)).json();
|
||||||
|
assert.equal(metadata.signatureAvailable,false);
|
||||||
|
assert.equal(metadata.signatureVerifiedByWorker,false);
|
||||||
|
assert.equal(metadata.signedMetadata,null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('public certificate is independent of unavailable key sources and never cached',async()=>{
|
||||||
|
assert.equal((await fetch(request('/public-key.gpg'),env)).status,404);
|
||||||
|
const certificate='-----BEGIN PGP PUBLIC KEY BLOCK-----\nfixture-public-only\n-----END PGP PUBLIC KEY BLOCK-----\n';
|
||||||
|
const handler=createHandler({...release,publicCertificate:certificate,keys:[],allowEmpty:false,fallbackSources:['https://bad.example.com/keys']},{fetchImpl:async()=>{throw Error('certificate must not resolve keys')}});
|
||||||
|
const response=await handler(request('/public-key.gpg'),env);
|
||||||
|
assert.equal(response.status,200);assert.equal(await response.text(),certificate);
|
||||||
|
assert.equal(response.headers.get('Cache-Control'),'no-store');
|
||||||
|
assert.equal(response.headers.get('Content-Type'),'application/pgp-keys');
|
||||||
|
assert.equal(await(await handler(request('/public-key.gpg',{method:'HEAD'}),env)).text(),'');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signature endpoint pairs with exact signed document, never the legacy wrapper',async()=>{
|
||||||
|
const {makeSignedDocument}=await import('../cf-worker/src/signed-release.js');
|
||||||
|
const document=await makeSignedDocument(fixture,{listId:'test-list',sequence:1,issuedAt:1700000000,expiresAt:1900000000});
|
||||||
|
// Structurally valid, deliberately non-cryptographic armor tests routing only.
|
||||||
|
const signature='-----BEGIN PGP SIGNATURE-----\n\nYWJj\n-----END PGP SIGNATURE-----\n';
|
||||||
|
const handler=createHandler({...release,signedDocument:document,signature});
|
||||||
|
const keysResponse=await handler(request('/ssh.keys'),env);
|
||||||
|
assert.equal(keysResponse.status,200);assert.equal(await keysResponse.text(),document);
|
||||||
|
const sigResponse=await handler(request('/ssh.keys.sig'),env);
|
||||||
|
assert.equal(sigResponse.status,200);assert.equal(await sigResponse.text(),signature);
|
||||||
|
assert.equal(sigResponse.headers.get('Content-Type'),'application/pgp-signature');
|
||||||
|
assert.equal(sigResponse.headers.get('Cache-Control'),'no-store');
|
||||||
|
const metadata=await(await handler(request('/release.json'),env)).json();
|
||||||
|
assert.equal(metadata.signatureAvailable,true);assert.equal(metadata.signatureVerifiedByWorker,false);
|
||||||
|
assert.equal(metadata.signedMetadata.sequence,1);
|
||||||
|
assert.equal(metadata.keyRevision,metadata.signedMetadata.documentSha256);
|
||||||
|
const unsigned=createHandler({...release,signedDocument:document,signature:null});
|
||||||
|
assert.equal(await(await unsigned(request('/ssh.keys'),env)).text(),document);
|
||||||
|
assert.equal((await unsigned(request('/ssh.keys.sig'),env)).status,404);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user