${escapeHTML(key.label)}
${escapeHTML(key.type)}${escapeHTML(key.fingerprint)}
${codeBlock(`key-${i}`,key.text,`复制第 ${i + 1} 个公钥`)}diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 547e0f1..f2bcc19 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -20,12 +20,13 @@ jobs: - run: npm ci - name: Install shell test interpreters run: | - if ! command -v busybox >/dev/null || ! command -v shellcheck >/dev/null; then + 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 + 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: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6a5fc61..02cd491 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,16 +9,40 @@ 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. - # Configure production credentials and real server keys before enabling. + # 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: @@ -27,12 +51,13 @@ jobs: - run: npm ci - name: Install shell test interpreters run: | - if ! command -v busybox >/dev/null || ! command -v shellcheck >/dev/null; then + 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 + 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' @@ -40,9 +65,34 @@ jobs: run: npm run build - run: node scripts/verify-release.mjs - name: Deploy the validated production bundle - run: npx wrangler deploy --config cf-worker/wrangler.toml + 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' }} diff --git a/README.md b/README.md index 1002c2e..e9a52c3 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,49 @@ # Quickly Configuring SSH Server -在 Git 仓库中独立管理服务器 SSH 公钥,通过 Cloudflare Worker 发布,再由服务器定期同步。 +在 Git 仓库或指定账号的 Gist 中独立管理服务器 SSH 公钥,通过同一个 Cloudflare Worker 发布网页、脚本、公钥清单、签名和公钥证书。 -- **优先使用本仓库的 `keys/authorized_keys`**;主清单为空时,可按配置顺序读取备用 HTTPS 公钥源。没有默认 GitHub 账号 SSH Keys 来源。 -- 网页、脚本与仓库公钥一起构建发布,不再依赖 GitHub Pages;备用公钥独立标记来源和内容摘要。 -- 首页支持浅色 / 深色 / 跟随系统、手机与宽屏,以及 GitHub 风格的代码块复制按钮。 -- POSIX Shell 客户端只替换自己的密钥管理区块,保留手工密钥;下载或校验失败保留上次成功的内容。 +- 不默认读取 GitHub 账号的 SSH 登录钥匙,不依赖独立 GitHub Pages 部署。 +- 普通模式仅校验公钥格式;可选高信任模式由**服务器脚本**验证 GPG 签名,Worker 无法远程关闭它。 +- 脚本落盘后固定,定时任务只同步公钥。升级、修改信任指纹或关闭验签需要显式重新安装。 +- 首页有浅色 / 深色 / 系统主题、响应式布局和逐条命令复制;不显示或内嵌公钥正文、指纹、个人注释。 -> 仓库初始没有服务器公钥,生产构建会拒绝空清单。先填写你独立管理的服务器**公钥**或显式配置备用源;不要上传私钥,也不要直接拿测试夹具上线。 +> 初始生产清单和备用源为空,没有配置实际签名证书或发布凭据。生产构建不会偷偷使用测试密钥。仓库中的公钥文件不是 GitHub 账号登录凭据。 -## Quick Usage +## 使用与重新安装 -以下地址是部署配置中的示例。Fork 后先修改 `deployment.json`,填写公钥并完成自己的 Worker 部署,再使用自己的域名。执行前建议下载并检查脚本。 +先配置并部署自己的 Worker,再使用自己的域名。以下地址只是本项目的部署配置示例。 -安装公钥并设置定时同步: +普通 HTTPS 安装 / 显式重新安装并启用定时同步: ```sh -curl -fsSL 'https://ssh.lamgc.me/script.sh' | sh -s -- -c +curl -fsSL 'https://ssh.lamgc.me/script.sh' | sh -s -- --install --download-script -c ``` -新安装默认每 15 分钟同步一次;已有的有效自定义计划会保留。只更新一次: +再次执行安装命令会检测已安装脚本,备份并升级。签名策略只在已替换旧客户端之后激活;若后续磁盘写入失败,可能留下新脚本和未完成的配置,请按错误信息检查并显式重新安装,不要把失败当作策略已启用。已有本地配置和自定义定时计划默认保留,不因远端仓库默认值变化而重置。新安装默认每 15 分钟同步。 + +**这条命令信任下载到的可执行脚本。** HTTPS、同一 Worker 提供的摘要和后续公钥验签,不能证明首次安装器本身可信。高信任安装应先取得独立渠道核对过的脚本摘要和完整 GPG 指纹,下载、核验本地文件后执行安装。不要把从同一被攻陷来源获得的指纹当作独立信任依据。 + +日常同步、预览、状态使用已经落盘的脚本: ```sh -curl -fsSL 'https://ssh.lamgc.me/script.sh' | sh -s -- -o -``` - -预览、帮助与本地状态: - -```sh -curl -fsSL 'https://ssh.lamgc.me/script.sh' | sh -s -- -o --dry-run +sh "$HOME/.conf-sshd/conf-sshd.sh" -o ``` ```sh -curl -fsSL 'https://ssh.lamgc.me/script.sh' | sh -s -- -h +sh "$HOME/.conf-sshd/conf-sshd.sh" -o --dry-run ``` ```sh sh "$HOME/.conf-sshd/conf-sshd.sh" --status ``` -指定计划、取消计划、单独更新已安装脚本: +查看完整参数,不修改配置: + +```sh +curl -fsSL 'https://ssh.lamgc.me/script.sh' | sh -s -- -h +``` + +指定 / 取消自己的定时计划: ```sh sh "$HOME/.conf-sshd/conf-sshd.sh" -c '0 */6 * * *' @@ -49,73 +53,118 @@ sh "$HOME/.conf-sshd/conf-sshd.sh" -c '0 */6 * * *' sh "$HOME/.conf-sshd/conf-sshd.sh" -c false ``` -```sh -sh "$HOME/.conf-sshd/conf-sshd.sh" -u -``` - -卸载定时同步与脚本,保留密钥及 SSH 服务配置: - ```sh sh "$HOME/.conf-sshd/conf-sshd.sh" --uninstall ``` -这些命令用途不同,按需单独执行。网页只提供逐块复制,不把安装、卸载、密码策略合成“执行全部”。 +卸载保留授权密钥、SSH 服务配置、本地信任配置与防回滚状态。`-u` 不再下载或替换脚本,会提示显式重新安装。没有自动更新周期;延长更新周期本身也不是安全验证。 -## 公钥清单与发布 +## Git / Gist 管理模式 -将普通 OpenSSH 公钥逐行放入 `keys/authorized_keys`,可附注释和空行。构建会规范化换行与编码,逐条调用 `ssh-keygen` 校验,拒绝重复钥匙、私钥、无效编码和未知行。 +### Git -支持 ED25519、RSA(至少 2048 位)、ECDSA nistp256/384/521 和 OpenSSH 安全密钥类型。第一版不接受 DSA、证书或 `command=`、`environment=` 等 authorized_keys 选项。私钥应始终保留在你的客户端或安全密钥设备上。 +`keySource.type` 为 `git`(默认)。普通公钥逐行放入 `keys/authorized_keys`;构建校验并去掉所有注释,再发布。若注释曾提交公开仓库,它仍可能存在于历史中,因此发布前也应从源文件去掉个人信息。 -配置文件 `deployment.json`: +支持 ED25519、RSA(2048–16384 位)、ECDSA nistp256/384/521 和 OpenSSH 安全密钥格式。不接受 DSA、SSH 证书、`command=` 等 authorized_keys 选项、重复公钥或私钥。 -- `baseUrl`:Worker 的正式 HTTPS origin,不带路径或末尾斜杠。 -- `repositoryUrl`:自己的项目仓库地址。 -- `defaultCron`:新安装的默认同步计划,默认 `*/15 * * * *`。 -- `allowEmpty`:默认 `false`;明确设为 `true` 时,空主清单表示撤销全部,禁止回退到备用源。 -- `fallbackSources`:默认空数组。可填写最多 5 个公开 HTTPS URL,按顺序尝试;不能带凭据、查询参数或片段,不接受请求端传入的地址。 +签名发布使用精确签名文档和 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 +``` -只有仓库清单为空,且 `allowEmpty` 不是 `true` 时,Worker 才依次读取 `fallbackSources`。取第一个成功且有效的非空公钥集合,不聚合多个源。仓库清单非空始终优先;明确撤销全部时不调用任何备用源。 +上例时间仅展示参数格式,不应照抄为正式有效期。工具会拒绝覆盖已有文件;更新时先保留旧版并明确选择新输出,再审阅替换。清空发布必须使用空输入并加 `--revoke`。 -每个备用源有 3 秒超时(含正文读取)、1 MiB 正文限制、最多 1000 个公钥,并拒绝重定向。非 2xx、空正文、无效 UTF-8、私钥/选项/证书、损坏密钥或重复密钥会跳过并尝试下一个。全部失败时 `/ssh.keys` 和 `/release.json` 返回 503,首页说明暂不可用;客户端保留上次成功的密钥。 +审核精确文档后,使用你自己的授权签名钥匙手动签名: -不设置 Worker 备用公钥缓存,不使用陈旧内容兜底;每个相关请求重新获取,要求上游也正确提供当前数据。首页的一次响应中,公钥、复制内容、来源、数量与内容摘要来自同一个结果。不同时间请求可能看到备用源的不同版本,因此页面分别显示代码修订与公钥摘要,不能只用 Git 提交号代表动态公钥版本。 +```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` 同时存在。 -### 明确撤销全部管理密钥 -确实需要删除全部受管理密钥时,在同一提交中清空公钥清单,并显式将 `allowEmpty` 改为 `true`。发布与新客户端都会检查数量、版本和内容摘要,只有合法的明确空清单才会清空管理区块。 +### Gist -先确认其他恢复登录方式可用。手工放在管理区块之外的密钥不会删除;同一把钥匙若还存在于其他 authorized_keys 来源,仍可能允许登录。删除密钥也不会终止已有 SSH 会话。 +配置固定的 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 才是身份固定值。 -服务器密钥与 GitHub 账号认证密钥相互独立。在仓库里保存公钥不会把它注册为 GitHub 账号登录凭据。 +更新签名文档后必须重新签名。Worker 不持有签名私钥,不会为 Gist 的任意修改自动背书。普通模式可以发布无签名清单;客户端是否验签由其本地策略决定。 -但能修改并发布这个项目的人,以及被配置为备用公钥源的发布者,都能改变使用它的服务器的授权密钥。请保护仓库主分支、CI、Cloudflare 账号和部署权限。HTTPS 保护传输,SHA-256 用于检测截断或版本不一致,**不能抵抗发布源本身被攻陷**。`curl | sh` 仍意味着信任发布的可执行脚本;定时任务只拉公钥,不会自动更新脚本。 +Gist 配置示例(将 ID 与账号数字 ID 替换为经过核对的真实值): -## 安装与部署 +```json +{"keySource":{"type":"gist","id":"0123456789abcdef0123456789abcdef","ownerId":123456,"ownerLogin":"example","file":"ssh.keys","signatureFile":"ssh.keys.sig","bundleFile":"release.bundle"}} +``` -### 前提 +### 可见性 -构建环境需要 Node.js 22+、npm、Git、OpenSSH 客户端工具(`ssh-keygen`)及 POSIX `sh`。依赖固定在 `package-lock.json`。 +网页不嵌入密钥,不代表数据保密。`/ssh.keys` 可公开读取,公开 Git/Gist 和历史也可访问;去掉注释后,相同公钥字节仍能被关联。私钥始终留在使用者或可信签名端,不能上传本项目、Worker、Actions 或 Gist。 -客户端需要 `sh`、`curl`、`ssh-keygen`、`awk`、`mktemp`、`stat` 和常用 Unix 工具,以及 `sha256sum` / `shasum` / `openssl` 之一;设置定时同步还需 `crontab`。请自行安装所需软件。脚本不安装包、不启动或重载服务;`-n` 保留为兼容参数。 +## 客户端 GPG 验证(默认 OFF) -目标为 Linux 上的 dash、Bash 的 sh 用法和 BusyBox ash。真实发行版服务管理、SELinux 标签或自定义 `AuthorizedKeysFile` 不应仅凭单元测试视为已验证。默认目标文件是当前用户的 `$HOME/.ssh/authorized_keys`;自定义 SSH 授权路径需要人工核对。 +- **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;网络返回的其他钥匙不能扩大本地授权。 -### 一次性配置 +仓库可设置新安装默认值,安装时可显式覆盖。**已安装机器的本地配置优先**,之后改仓库默认值不能关闭它的验证。开启验证、换指纹/信任范围或关闭已有验证都必须显示具体信息并取得本地明确确认;取消或无法确认时不应用修改。 -1. Fork / 克隆仓库,填入自己的服务器公钥和 `deployment.json`。 -2. 按需要修改 `cf-worker/wrangler.toml` 中的 Worker 名称,并为 `baseUrl` 配置 Worker 自定义域名。无需启用 GitHub Pages。 -3. 安装依赖、运行检查、构建生产产物: +安装在本地明确确认后依序寻找对应证书:已有本地证书、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 signer@example.com +``` + +占位指纹必须替换为真实完整指纹;Email 可省略。安装会在本地终端要求输入展示的完整指纹确认,没有静默确认参数。关闭已有验证使用显式重新安装加 `--gpg-verification off`,同样需要终端确认。重新安装可用 `--fallback-urls-file` 指定按行排列的备用文档 URL;`--keyserver` 可重复覆盖内置列表,原有设置默认保留。 + +### 签名文档、防回滚与故障 + +列表 ID 使用 1–64 个字母、数字、点、下划线或连字符,单独的 `-` 保留给本地未配置状态,不能作为列表名。 + +签名文档使用固定 LF 格式:协议标记、列表 ID、单调递增序号、签发/到期 Unix 秒、明确撤销标志、数量、结束标记,以及无注释的规范公钥正文。签名覆盖精确全部字节。 + +ON 客户端检查固定列表 ID、有效期和持久化的最高序号/摘要。低序号拒绝;同序号只接受同一文档;明确撤销也必须签名并增加序号。首次安装的最低序号应通过可信渠道确定,否则仅有指纹不能排除有效期内的旧发布。 + +更新使用锁、待提交记录、快照检查和原子替换来处理进程中断。待提交发布已过期时不会强行应用,保留其序号下限后允许有效的新发布继续;损坏且无法可信恢复的状态会停止并提示人工检查。不要删除或恢复旧版本的本地防回滚状态;整机快照回滚、本地管理员篡改或底层文件系统掉电持久性不由一个 POSIX 脚本保证。 + +下载/验签失败或清单过期会保留最后成功的密钥并报错,**不会自动清空密钥造成锁死**。攻击者仍可阻断更新;离线机器无法保证及时撤权。删除公钥不会终止已有会话,也不会移除管理区块外的手工重复钥匙。部署签名私钥或服务器本地管理员权限失陷超出这层保护范围。 + +## 有序备用源 + +有两份有意区分的配置: + +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。脚本不会自动安装软件。 ```sh npm ci @@ -124,72 +173,61 @@ npm run build node scripts/verify-release.mjs ``` -4. 在 GitHub 的生产环境中配置最小权限的 Cloudflare 部署 token 和账号 ID,对应 `CLOUDFLARE_API_TOKEN`、`CLOUDFLARE_ACCOUNT_ID`。凭据只存放在 Secrets,不能提交到代码或公钥清单。 -5. 准备好正式公钥、域名和权限后,将仓库变量 `ENABLE_WORKER_DEPLOY` 设为 `true`。此后 main 的更新会运行生产发布流程,也可手动触发。 +正式上线前配置自己的域名、Worker 名称、正式公钥/签名和最小权限 Cloudflare Secrets。将仓库 `ENABLE_WORKER_DEPLOY=true` 才启用 main 发布;默认关闭。PR 测试不部署。 -默认关闭自动生产发布,防止合并本次改动时意外上线。PR 验证不会读取部署凭据或发布 Worker。 +### 可选 Git 提交签名门禁 -生产流程先测试和严格构建,再部署,最后核对 `/release.json` 和 `/ssh.keys` 的版本与摘要。没有正式密钥、没有备用源且未显式允许空清单时会失败;不会偷偷改用 GitHub Keys 或测试密钥。 +`REQUIRE_GPG_SIGNATURE=true` 时,发布验证任务先验证本次精确 `GITHUB_SHA`,然后才执行依赖安装、测试和构建。授权完整主指纹和公钥从 GitHub 设置中的 `GPG_ALLOWED_PRIMARY_FINGERPRINTS`、`GPG_PUBLIC_KEYS` 读取,而不是让待部署提交自行指定。 -发布脚本、网页、仓库公钥和备用源配置属于同一构建产物;动态备用公钥有单独的内容摘要。若要回滚历史版本,先查看公钥差异:回滚可能重新启用已撤销的密钥。部署失败保留旧发布,但不能把这描述为新密钥已生效。 +GitHub 的通用 Verified 标记不是本项目授权名单。最终 merge/squash 提交必须由授权 GPG 钥匙签署;仅 PR 内旧提交签名不够。手动发布也不能绕过同一验证。 -## Paths +**这是附加门禁,不能替代服务器验签。** 同仓库 workflow/验证器可被有权限者修改,需配合主分支与工作流强制审阅、受保护 production 环境和最小权限凭据。能修改验证工作流并访问发布凭据的人仍在信任边界内。 + +### 可选部署邮件通知 hook + +默认关闭。通过 `ENABLE_MAIL_NOTIFICATION`、`MAIL_NOTIFICATION_WEBHOOK` 和可选 `MAIL_NOTIFICATION_TO` 接入你控制的邮件提供方 webhook;本项目不内置 SMTP 服务,也不会自行创建凭据。 + +通知说明部署结果、代码 SHA、来源模式、列表身份和**部署配置中的**备用 URL。它不是服务器清单,不知道各机器后来修改的本地备用源,也不是每次 Gist 内容变更的监视器。通知失败或被压制不能当作部署未发生/系统安全的证明。 + +## API | 路径 | 内容 | | --- | --- | -| `/` | 浏览器首页;旧 curl/libcurl 请求仍返回脚本(显式请求 HTML 除外) | -| `/script.sh` | Shell 脚本,推荐所有新命令使用这个明确地址 | -| `/ssh.keys` | 带注释元数据的普通公钥文本 | -| `/release.json` | 当前代码版本、有效公钥来源、数量、摘要和空清单状态 | +| `/` | 浏览器首页;curl/libcurl/wget/HTTPie 返回脚本,显式 HTML 优先 | +| `/script.sh` | 安装脚本 | +| `/ssh.keys` | 精确公钥文档;签名存在时就是被签名的原始字节 | +| `/ssh.keys.sig` | 对上述文档的 detached GPG 签名;没有可用签名时 404 | +| `/public-key.gpg` | 配置的公共 GPG 证书;未配置时 404 | +| `/release.json` | 来源、数量、摘要、代码修订及签名是否提供;不表示服务器已验签 | -动态入口不缓存旧数据;带内容哈希的静态 CSS / JS 可以长期缓存。未知路径返回 404,不能用首页 HTML 代替密钥或脚本错误。 +脚本、Git 公钥/签名与网站一起构建发布;不需要额外 Pages。动态 Gist/备用数据的序号和摘要与代码修订分别表示。静态 CSS/JS 使用内容哈希缓存,未知接口返回 404,不返回 HTML 冒充密钥。 -## 客户端保护措施 +## 从旧版迁移 -- 所有参数先解析;未知选项、缺参数或冲突用途会报错。 -- 下载具有 HTTPS 限制、HTTP 失败检测、超时、有限重试和大小限制。 -- 响应包含协议版本、Git revision、密钥数量、明确空清单标记和 SHA-256;所有公钥逐条校验。 -- 每个用户独占运行锁;临时文件使用不可预测名称、严格权限和异常清理。 -- 只识别唯一且配对的原有 BEGIN / END 管理标记,损坏或重复时停止,避免误吞手工密钥。 -- 同目录原子替换,写入前保存一个受限备份;重复执行内容相同时不重写文件。 -- 拒绝符号链接、不合适的所有权/写权限和异常文件类型。 -- `-c` 只管理自己的 cron 区块;原版已知命令可迁移,无法可靠判断的记录会停止并提示。 -- 正常密钥同步不会修改 `sshd_config`,也不会重载 SSH 服务。 +1. 保留恢复会话和备用登录方式,先核对独立服务器清单。 +2. 显式重新安装新版脚本,检查本地配置、自定义计划、管理区块和手工钥匙。 +3. 逐台核对脚本版本、完整指纹、验证模式、列表 ID 和序号;等待一段时间不是所有服务器升级完成的证据。 +4. 再切换新的签名文档格式并按确认流程启用 ON。新版 OFF 可以解析新文档但不验签;旧 v1 客户端会拒绝新格式并保留旧钥匙,不能把它视为已得到保护。 +5. 另开 SSH 会话验证登录。回滚旧 Worker 可能重新发布历史钥匙;ON 客户端有序号防回滚,但不要依赖普通模式抵抗历史重放。 -如果运行被不可捕获地终止,锁可能残留。先检查 `.conf-sshd/lock/pid` 对应的进程及工作状态,再人工处理锁,不能仅凭锁的年龄删除。 +## 客户端操作边界 -### Root 密码登录(高级) +仅操作当前用户 `$HOME/.ssh/authorized_keys` 的唯一配对管理区块。临时文件、锁、备份、同目录替换和写前快照检查避免可观察到的并发覆盖;不同编辑器不共享锁时仍无操作系统级原子比较交换保证。 -保留 `-p yes` / `-p no`,必须单独显式使用并以 root 运行。不属于安装/同步默认流程。它只设置 `PermitRootLogin`,不会替你打开其他认证策略。 +`-p yes/no` 是独立 root 高级操作,仅修改 PermitRootLogin,验证 `sshd -t/-T` 后替换,遇 Include/Match 等复杂配置拒绝;不会自动 reload/restart。普通安装/同步不修改 SSH 服务策略。 -脚本会检查原配置、生成临时配置、验证语法与生效值、备份后替换,**不会自动 reload/restart**。发现 Include / Match 等无法安全判断的组合时,会拒绝修改并请你人工处理。应用服务变更前应保留恢复会话并测试新连接。 +目标为 Linux dash、Bash POSIX 与 BusyBox ash。测试不等于真实服务器登录、SELinux、发行版服务管理或自定义 AuthorizedKeysFile 已验证。异常强制终止可能留下锁,人工确认原进程状态后再处理。 -## 从旧版本迁移 - -1. 先建立独立服务器公钥清单并发布新 Worker;或显式配置备用源;不会自动导入 GitHub 账号公钥。 -2. 检查页面公钥、版本和 `/ssh.keys`,确认是你的服务器密钥。 -3. 显式更新已安装脚本,或从新的 `/script.sh` 下载并检查后重新运行 `-c`。 -4. 验证自定义 cron 仍保留,手工密钥不变,另开 SSH 会话验证登录。 - -旧客户端对非空的 `/ssh.keys` 仍有基本格式兼容,但不具备新验证、明确空清单撤权与其他保护。不要因为 Worker 更新了就认为所有客户端已升级。 - -## 开发与测试 +## 测试 ```sh -npm ci npm test npm run build:test npx wrangler deploy --dry-run --config cf-worker/wrangler.toml ``` -`build:test` 只用于测试/预览,使用公开的 RFC 测试公钥,并在产物中标记 `testFixture: true`。生产验证脚本拒绝该产物;生产构建只使用仓库公钥和显式配置的备用源,不允许回退到测试夹具。 - -测试覆盖构建配置与注入边界、逐条密钥验证、Worker 路由/缓存、主题/复制交互、Shell 参数/管理区块/失败保留/cron/自更新等。Shell 测试只在临时 HOME、假命令和测试文件中运行,不操作真实 SSH 配置或服务。 - -`npm run build:test` 之后若要部署正式版本,必须重新执行 `npm run build` 与 `node scripts/verify-release.mjs`。不得直接部署测试产物。 +测试只使用临时 HOME、命令替身、公开测试 SSH 钥匙及临时无身份/无访问权限的 GPG 测试钥匙。真实浏览器测试仅访问 localhost。`build:test` 产物标记为测试,生产验证拒绝它;上线必须重新生产构建。 ## License Apache-2.0,见 [LICENSE](LICENSE)。 - -客户端会检测写入期间可观察到的手工修改并停止;其他编辑器若不遵守同一锁,仍无法保证操作系统级的原子比较并交换。请避免同时手工编辑授权文件。 diff --git a/cf-worker/src/key-source.js b/cf-worker/src/key-source.js index 04b2189..4484c94 100644 --- a/cf-worker/src/key-source.js +++ b/cf-worker/src/key-source.js @@ -1,3 +1,5 @@ +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(); @@ -133,15 +135,42 @@ 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 = keys.map(key => `${key.text}\n`).join(''); + 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) { - const bundle = await makeKeyBundle(keys, release.revision, release.allowEmpty); - return {keys, keysText: bundle.text, sha256: bundle.sha256, keyCount: keys.length, allowEmpty: release.allowEmpty, source, revision: release.revision, keyRevision: bundle.sha256, fetchedAt: new Date().toISOString()}; +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]; @@ -181,43 +210,125 @@ async function readBody(response, signal, maxBytes) { reader.releaseLock(); } } -async function fetchSource(url, fetchImpl, limits) { +async function withDeadline(limits, operation, onTimeout) { const controller = new AbortController(); - let timer, response; - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => { reject(new SourceError('timeout')); controller.abort(); }, limits.timeoutMs); + 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); }); - const operation = (async () => { - try { - response = await fetchImpl(url, {method: 'GET', redirect: 'manual', cache: 'no-store', headers: {'Accept': 'text/plain, application/octet-stream;q=0.9', 'Cache-Control': 'no-cache, no-store', 'Pragma': 'no-cache'}, signal: controller.signal}); - if (controller.signal.aborted) throw new SourceError('timeout'); - if (response.redirected || (response.status >= 300 && response.status < 400)) throw new SourceError('redirect', response.status); - if (!response.ok) throw new SourceError('http-error', response.status); - const body = await readBody(response, controller.signal, limits.maxBytes); - let keys; - try { keys = await parsePublicKeys(body, {maxKeys: limits.maxKeys, signal: controller.signal}); } catch { throw new SourceError('invalid-keys'); } - if (!keys.length) throw new SourceError('empty'); - return keys; - } finally { - if (response?.body && !response.body.locked) void response.body.cancel().catch(() => {}); - } - })(); - try { return await Promise.race([operation, timeout]); } + 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 URLs are accepted; callers must never derive release data from a request. */ +/** 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.'); - 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'}); - const sources = validateFallbackSources(release.fallbackSources); + 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 { return await snapshot(await fetchSource(url, fetchImpl, limits), release, {type: 'fallback', url}); } - catch (error) { diagnostics.push({index, code: error instanceof SourceError ? error.code : 'network-error', ...(error instanceof SourceError && error.status !== undefined ? {status: error.status} : {})}); } + 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} : {})}; +} diff --git a/cf-worker/src/router.js b/cf-worker/src/router.js index a6876cf..ef65116 100644 --- a/cf-worker/src/router.js +++ b/cf-worker/src/router.js @@ -23,10 +23,14 @@ export function createHandler(release, {fetchImpl = globalThis.fetch} = {}) { 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','/release.json','/'].includes(url.pathname)) { + 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)\//i.test(request.headers.get('User-Agent') || ''); + 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)}; @@ -40,17 +44,26 @@ export function createHandler(release, {fetchImpl = globalThis.fetch} = {}) { } } 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.sha256, - source:snapshot.source,fetchedAt:snapshot.fetchedAt,testFixture:Boolean(release.testFixture)})+'\n','application/json; charset=utf-8'); + 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)\//i.test(agent)) { + 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); diff --git a/cf-worker/src/signed-release.js b/cf-worker/src/signed-release.js new file mode 100644 index 0000000..b287538 --- /dev/null +++ b/cf-worker/src/signed-release.js @@ -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}; +} diff --git a/cf-worker/src/view.js b/cf-worker/src/view.js index 467db4a..7230c16 100644 --- a/cf-worker/src/view.js +++ b/cf-worker/src/view.js @@ -1,46 +1,58 @@ +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 * * * *'}。`,command:`${fetch} -c`}, - {id:'once',title:'仅更新一次公钥',description:'更新为当前发布的公钥,不设置定时任务。',command:`${fetch} -o`}, - {id:'help',title:'查看帮助',description:'先了解全部参数与行为,不修改配置。',command:`${fetch} -h`}, - {id:'preview',title:'预览公钥更新',description:'校验当前发布并预览将要修改的文件,不应用更新。',command:`${fetch} -o --dry-run`}, - {id:'update',title:'单独更新已安装脚本',description:'显式更新客户端脚本;定时公钥同步不会更新可执行代码。',command:'sh "$HOME/.conf-sshd/conf-sshd.sh" -u',advanced:true}, - {id:'root-off',title:'禁止 root 使用密码登录',description:'仅 root 可执行。校验并修改 SSH 配置,不自动重载服务。',command:`${fetch} -p no`,advanced:true}, - {id:'root-on',title:'允许 root 使用密码登录',description:'仅 root 可执行,涉及登录安全;实际登录仍受其他 SSH 认证设置限制,不自动重载服务。',command:`${fetch} -p yes`,advanced:true}, - {id:'uninstall',title:'停止同步并卸载',description:'移除脚本及其定时任务,保留公钥与 SSH 配置。',command:'sh "$HOME/.conf-sshd/conf-sshd.sh" --uninstall',advanced:true}, + {id:'install',title:'安装或重新安装',description:`下载并安装客户端,再同步公钥与设置计划;发现旧版本时先备份并保留本地配置。保留已有计划,新安装计划为 ${config.defaultCron || '*/15 * * * *'}。首次安装或明确重装都会信任你执行的脚本,推荐先独立核验安装脚本;启用或更换信任须在服务器本地终端确认完整指纹。`,command:`${fetch} --install --download-script -c`}, + {id:'once',title:'同步一次公钥',description:'使用已安装的固定脚本与本地信任设置同步公钥,不更换脚本或定时计划。',command:`${installed} -o`}, + {id:'help',title:'查看帮助',description:'下载当前脚本并显示参数,不建立信任;执行前仍应检查下载内容。',command:`${fetch} -h`}, + {id:'preview',title:'预览公钥同步',description:'使用已安装的脚本与本地信任设置检查更新,预览将要修改的文件,不应用更新。',command:`${installed} -o --dry-run`}, + {id:'root-off',title:'禁止 root 使用密码登录',description:'仅 root 可执行。使用已安装的脚本校验并修改 SSH 配置,不自动重载服务。',command:`${installed} -p no`,advanced:true}, + {id:'root-on',title:'允许 root 使用密码登录',description:'仅 root 可执行,涉及登录安全;实际登录仍受其他 SSH 认证设置限制,不自动重载服务。',command:`${installed} -p yes`,advanced:true}, + {id:'uninstall',title:'停止同步并卸载',description:'移除脚本及其定时任务,保留公钥与 SSH 配置。',command:`${installed} --uninstall`,advanced:true}, ]; } function codeBlock(id, text, label) { return `
${escapeHTML(text)}${escapeHTML(key.fingerprint)}
${codeBlock(`key-${i}`,key.text,`复制第 ${i + 1} 个公钥`)}此版本明确撤销全部受管理公钥,手工管理的公钥仍保留。
'; -} export function renderCommands(commands) { const card = command => `${escapeHTML(command.description)}
${codeBlock(`command-${command.id}`,command.command,`复制“${command.title}”命令`)}测试预览:这里使用公开测试密钥,不能部署到真实服务器。
' : '', - SOURCE_LABEL:escapeHTML(sourceLabel),SOURCE_STATUS:escapeHTML(sourceStatus),CONTENT_REVISION:escapeHTML(snapshot.sha256?.slice(0,12) || '—'),KEYS_NOTICE:escapeHTML(notice), + COMMANDS_HTML:renderCommands(commands),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 ? '测试预览:这里使用公开测试数据,不能部署到真实服务器。
' : '', + CERTIFICATE_LINK:certificatePresent ? '公开签名证书 ' : '', + 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), }; return template.replace(/@@([A-Z_]+)@@/g, (_, token) => { if (!Object.hasOwn(replacements, token)) throw Error(`Unresolved page template token: ${token}`); diff --git a/conf-sshd.sh b/conf-sshd.sh index f62bd50..b06ae79 100755 --- a/conf-sshd.sh +++ b/conf-sshd.sh @@ -1,8 +1,14 @@ #!/bin/sh # conf-sshd-script-v2 +# conf-sshd-client-security-v1 # Generated configuration. Never source or eval downloaded data. BASE_URL='@@BASE_URL@@' DEFAULT_CRON='@@DEFAULT_CRON@@' +DEFAULT_GPG_VERIFY='@@DEFAULT_GPG_VERIFY@@' +DEFAULT_GPG_FINGERPRINT='@@DEFAULT_GPG_FINGERPRINT@@' +DEFAULT_GPG_PIN_MODE='@@DEFAULT_GPG_PIN_MODE@@' +DEFAULT_LIST_ID='@@DEFAULT_LIST_ID@@' +DEFAULT_GPG_EMAIL='@@DEFAULT_GPG_EMAIL@@' set -eu umask 077 @@ -22,6 +28,23 @@ temps= lock_owned=no created_state=no created_ssh=no +gpg_home= +download_script=no +requested_verification= +config_prepared=no +validation_only=no +enforce_freshness=yes +replace_pending=no +public_key_file= +requested_fingerprint= +requested_pin_mode= +requested_list_id= +requested_min_sequence= +fallback_urls_file= +primary_url= +requested_email= +requested_keyservers= +trust_options=no say() { printf '%s\n' "$*"; } fail() { printf 'conf-sshd: %s\n' "$*" >&2; exit 1; } @@ -32,7 +55,25 @@ Usage: sh conf-sshd.sh [options] -c, --cron ["five fields"|false] Sync and schedule; bare -c keeps an existing schedule, otherwise the configured default. -c false only removes this script's schedule. - -u, --update-self Explicitly update the installed script only + --install Install/reinstall this trusted local file; + retain local trust, URLs and existing schedule. + --download-script With --install: HTTPS bootstrap/reinstall from + Worker (explicit invocation only; never cron). + --gpg-verification on|off Explicitly enroll/change persisted verification; + default from repository, existing config wins + --gpg-required, --gpg-off Aliases for --gpg-verification on/off + --gpg-fingerprint FULL40HEX Independently verified full fingerprint + --gpg-pin-mode primary|subkey Primary (default) trusts its valid signing + subkeys; subkey permits only that exact subkey + --public-key-file FILE Explicit independently verified local certificate; + supersedes cached/discovered certificate + --list-id ID Required signed-list identity + --min-sequence N Enrollment floor, default 1; never lowers state + --gpg-email EMAIL Optional WKD identity during explicit install + --keyserver HKPS_URL Override built-in ordered keyservers (repeat) + --primary-url HTTPS_URL Local first key endpoint; defaults to Worker + --fallback-urls-file FILE Ordered one-HTTPS-endpoint-per-line fallbacks + -u, --update-self Refused: reinstall explicitly with --install --uninstall Remove own schedule/script; preserve keys/config -p, --allow-root-passwd yes|no Explicit root-only sshd_config edit; never reload -n, --no-install-sshd Compatibility flag; packages are never installed @@ -41,6 +82,29 @@ Usage: sh conf-sshd.sh [options] -h, --help Show this help -u, --uninstall, -p and --status are exclusive operations. -o cannot use -c or -p. +Trust/URL changes require explicit installation, or keys-only -o enrollment. +--install -c also schedules; --install always validates and syncs keys. +Running an installer from Worker explicitly trusts that bootstrap code. For higher +assurance, verify a downloaded file against an independently obtained SHA-256. +No installed script updates itself: cron runs only -o. Ordinary initial -c may +bootstrap from Worker over HTTPS; --install --download-script explicitly repeats +that bootstrap. HTTPS bootstrap trusts Worker and the transport at install time. +During enrollment, approve the full fingerprint and scope in your terminal. +No noninteractive trust-confirmation bypass is supported. Certificate discovery: +local public keyring, Worker /public-key.gpg, optional email WKD, then ordered +HKPS keyservers. Each candidate must match the complete primary/subkey pin. +Keyserver/WKD operators may learn the queried fingerprint/email and your IP. +A dedicated public-only certificate is saved locally; normal sync uses only it, +with no keyserver, WKD or certificate refresh. Reinstall reuses a valid local +certificate; --public-key-file explicitly supplies a verified replacement. +Built-in keyservers: hkps://keys.openpgp.org, hkps://keyserver.ubuntu.com. +A distributor can replay an old matching certificate that omits a revocation; +fingerprint pinning does not prove certificate freshness. +Strict mode needs GnuPG and a correct clock. A missing rollback-state file fails +closed. With --min-sequence omitted, initial enrollment cannot detect a previously +published, still-unexpired release being replayed; obtain a current floor out of band. +Expired/invalid responses retain last installed keys; expiry is not automatic revocation. +Local state uses atomic renames and a pending journal, not a power-loss fsync guarantee. Requires curl, ssh-keygen, awk, standard POSIX tools, mktemp, stat, and one of sha256sum, shasum or openssl. Scheduling also requires crontab. Downloads require HTTPS; existing keys survive any validation/download failure. @@ -61,6 +125,30 @@ while [ "$#" -gt 0 ]; do usage; exit 0 ;; -o|--only-update-keys) select_mode keys ;; -u|--update-self) select_mode update ;; + --install) select_mode install ;; + --download-script) [ "$download_script" = no ] || fail 'Duplicate --download-script.'; download_script=yes ;; + --gpg-required|--gpg-off) + [ -z "$requested_verification" ] || fail 'Duplicate GPG verification option.' + if [ "$1" = --gpg-required ]; then requested_verification=on; else requested_verification=off; fi + trust_options=yes ;; + --gpg-verification|--public-key-file|--fingerprint|--gpg-fingerprint|--gpg-pin-mode|--gpg-email|--keyserver|--list-id|--min-sequence|--fallback-urls-file|--primary-url) + option=$1 + [ "$#" -ge 2 ] && [ -n "$2" ] || fail "$option requires a value." + shift + case $option in + --gpg-verification) [ -z "$requested_verification" ] || fail "Duplicate $option."; requested_verification=$1 ;; + --public-key-file) [ -z "$public_key_file" ] || fail "Duplicate $option."; public_key_file=$1 ;; + --fingerprint|--gpg-fingerprint) [ -z "$requested_fingerprint" ] || fail "Duplicate $option."; requested_fingerprint=$1 ;; + --gpg-pin-mode) [ -z "$requested_pin_mode" ] || fail "Duplicate $option."; requested_pin_mode=$1 ;; + --list-id) [ -z "$requested_list_id" ] || fail "Duplicate $option."; requested_list_id=$1 ;; + --min-sequence) [ -z "$requested_min_sequence" ] || fail "Duplicate $option."; requested_min_sequence=$1 ;; + --fallback-urls-file) [ -z "$fallback_urls_file" ] || fail "Duplicate $option."; fallback_urls_file=$1 ;; + --primary-url) [ -z "$primary_url" ] || fail "Duplicate $option."; primary_url=$1 ;; + --gpg-email) [ -z "$requested_email" ] || fail "Duplicate $option."; requested_email=$1 ;; + --keyserver) requested_keyservers="$requested_keyservers$1 +" ;; + esac + trust_options=yes ;; --uninstall) select_mode uninstall ;; --status) select_mode status ;; -p|--allow-root-passwd) @@ -79,7 +167,11 @@ while [ "$#" -gt 0 ]; do esac shift done -[ "$cron_requested" = no ] || [ "$mode" = sync ] || fail '-c cannot be combined with this operation.' +[ "$cron_requested" = no ] || [ "$mode" = sync ] || [ "$mode" = install ] || fail '-c cannot be combined with this operation.' +[ "$trust_options" = no ] || [ "$mode" = install ] || [ "$mode" = keys ] || [ "$mode" = sync ] || fail 'Trust and URL settings require explicit installation or keys-only enrollment.' +case $requested_verification in ''|on|off) : ;; *) fail '--gpg-verification requires on or off.' ;; esac +[ "$download_script" = no ] || [ "$mode" = install ] || fail '--download-script requires --install.' +[ "$mode" != update ] || fail 'Remote self-update is disabled. Download and independently verify a local script, then run sh ./conf-sshd.sh --install. Explicit HTTPS bootstrap may use --install --download-script.' need() { command -v "$1" >/dev/null 2>&1 || fail "Missing dependency: $1. Install it explicitly before continuing."; } for dep in awk cat chmod cmp cp dd grep id mkdir mktemp mv od rm rmdir sed stat tail tr wc; do need "$dep"; done @@ -94,6 +186,10 @@ state=$HOME/.conf-sshd ssh_dir=$HOME/.ssh auth_file=$ssh_dir/authorized_keys target_script=$state/conf-sshd.sh +local_config=$state/local.conf +replay_state=$state/replay.state +pending_bundle=$state/pending.bundle +pending_state=$state/pending.state # GNU/Linux and BSD/macOS stat. No filesystem changes precede ownership checks. if stat -c '%u %a %h' "$HOME" >/dev/null 2>&1; then @@ -124,6 +220,10 @@ check_path "$state" dir check_path "$ssh_dir" dir check_path "$auth_file" file check_path "$target_script" file +check_path "$local_config" file +check_path "$replay_state" file +check_path "$pending_bundle" file +check_path "$pending_state" file validate_cron() { printf '%s\n' "$1" | awk ' @@ -151,12 +251,16 @@ if [ "$cron_requested" = yes ] && [ -n "$cron_value" ] && [ "$cron_value" != fal fi cleanup() { - result=$? + result=${1:-$?} trap - 0 HUP INT TERM old_ifs=$IFS; IFS=' ' for path in $temps; do rm -f "$path"; done IFS=$old_ifs + if [ -n "$gpg_home" ]; then + if command -v gpgconf >/dev/null 2>&1; then gpgconf --homedir "$gpg_home" --kill all >/dev/null 2>&1 || :; fi + rm -rf "$gpg_home" + fi if [ "$lock_owned" = yes ]; then rm -f "$state/lock/pid"; rmdir "$state/lock" 2>/dev/null || :; fi if [ "$dry_run" = yes ] || [ "$mode" = uninstall ]; then if [ "$created_ssh" = yes ]; then rmdir "$ssh_dir" 2>/dev/null || :; fi @@ -206,14 +310,22 @@ fi validate_build() { printf '%s\n' "$BASE_URL" | grep -Eq '^https://([A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?|\[[0-9A-Fa-f:]+\])(:[0-9]{1,5})?$' || fail 'BASE_URL must be a configured HTTPS origin.' validate_cron "$DEFAULT_CRON" >/dev/null || fail 'Invalid or unbuilt default cron configuration.' + case $DEFAULT_GPG_VERIFY in on|off) : ;; *) fail 'Invalid or unbuilt default GPG verification mode.' ;; esac + case $DEFAULT_GPG_PIN_MODE in primary|subkey) : ;; *) fail 'Invalid or unbuilt default pin mode.' ;; esac + if [ "$DEFAULT_GPG_VERIFY" = on ]; then + printf '%s\n' "$DEFAULT_GPG_FINGERPRINT" | grep -Eq '^[0-9A-Fa-f]{40}$' || fail 'Invalid default GPG fingerprint.' + printf '%s\n' "$DEFAULT_LIST_ID" | grep -Eq '^[A-Za-z0-9._-]{1,64}$' || fail 'Invalid default signed list ID.' + fi } download() { need curl - # No redirects: a configured origin must serve these endpoints directly. - curl --fail --silent --show-error --proto '=https' --tlsv1.2 \ - --connect-timeout 10 --max-time 30 --retry 2 --retry-delay 1 \ - --retry-max-time 65 --max-filesize 1048576 --output "$2" "$1" || fail "Download failed: $1 (existing files unchanged)." - [ "$(wc -c < "$2" | tr -d ' ')" -le 1048576 ] || fail 'Downloaded response is too large.' + download_limit=${3:-1048576} + # No redirects. Partial/error bodies are never parsed as a successful source. + http_status=$(curl --disable --fail --silent --show-error --proto '=https' --tlsv1.2 \ + --connect-timeout 10 --max-time 30 --retry 0 \ + --max-filesize "$download_limit" --output "$2" --write-out '%{http_code}' "$1") || return 1 + [ "$http_status" = 200 ] || return 1 + [ "$(wc -c < "$2" | tr -d ' ')" -le "$download_limit" ] } sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' @@ -249,20 +361,30 @@ replace_file() { mv -f "$staged" "$destination" } validate_script() { - sh -n "$1" || fail 'Downloaded script has invalid shell syntax.' - [ "$(grep -Fxc '# conf-sshd-script-v2' "$1" || :)" = 1 ] || fail 'Downloaded script is missing its unique version marker.' - [ "$(grep -Fxc "BASE_URL='$BASE_URL'" "$1" || :)" = 1 ] || fail 'Downloaded script changed its configured origin.' - [ "$(grep -Fxc "DEFAULT_CRON='$DEFAULT_CRON'" "$1" || :)" = 1 ] || fail 'Downloaded script has invalid default cron configuration.' - if grep -Eq '@@[A-Z_]+@@' "$1"; then fail 'Downloaded script contains unexpanded build tokens.'; fi + sh -n "$1" || fail 'Installer script has invalid shell syntax.' + [ "$(grep -Fxc '# conf-sshd-script-v2' "$1" || :)" = 1 ] || fail 'Installer script is missing its unique version marker.' + [ "$(grep -Fxc '# conf-sshd-client-security-v1' "$1" || :)" = 1 ] || fail 'Installer script lacks the fixed-client security version marker.' + [ "$(grep -Fxc "BASE_URL='$BASE_URL'" "$1" || :)" = 1 ] || fail 'Installer script changed its configured origin.' + [ "$(grep -Fxc "DEFAULT_CRON='$DEFAULT_CRON'" "$1" || :)" = 1 ] || fail 'Installer script has invalid default cron configuration.' + if grep -Eq '@@[A-Z_]+@@' "$1"; then fail 'Installer script contains unexpanded build tokens.'; fi } -install_script() { +prepare_script() { validate_build new_temp "$state"; script_tmp=$TEMP - # In a piped invocation $0 can name an unrelated cwd file. Always fetch the - # configured origin, rather than guessing whether $0 is this script. - download "$BASE_URL/script.sh" "$script_tmp" + if [ "$download_script" = yes ] || [ "$mode" != install ]; then + download "$BASE_URL/script.sh" "$script_tmp" || fail 'Installer download failed; existing script is unchanged.' + else + case $0 in /*|./*|../*) : ;; *) fail '--install requires an unambiguous local file path (sh ./conf-sshd.sh), or ordinary --download-script.' ;; esac + case $0 in /dev/*|/proc/*|/sys/*) fail '--install cannot use stdin, a pipe, or a device file.' ;; esac + check_path "$0" file + [ -f "$0" ] || fail '--install requires a real local script file.' + cp "$0" "$script_tmp" + fi validate_script "$script_tmp" +} +install_script() { replace_file "$target_script" "$script_tmp" 700 + say 'Installed local script. Scheduled runs update keys only.' } # Rewrite only an exact paired managed block or the exact known legacy command. @@ -327,11 +449,7 @@ apply_cron() { say "Managed cron set: $1" } -sync_keys() { - need ssh-keygen - validate_build - new_temp "$state"; response=$TEMP - download "$BASE_URL/ssh.keys" "$response" +validate_legacy_response() { last_lf "$response" || fail 'Key response must end with LF.' new_temp "$state"; invalid_bytes=$TEMP tr -d '\012\040-\176\200-\377' < "$response" > "$invalid_bytes" @@ -354,8 +472,12 @@ sync_keys() { [ "$(sha256 "$payload")" = "$expected_hash" ] || fail 'Payload SHA-256 mismatch.' [ "$(wc -l < "$payload" | tr -d ' ')" = "$key_count" ] || fail 'Payload key count mismatch.' if [ "$key_count" = 0 ]; then [ "$allow_empty" = yes ] || fail 'Empty key set was not explicitly allowed.'; fi - awk ' + validate_payload +} +validate_payload() { + awk -v strict="$policy" ' { + if(strict=="required" && NF!=2) exit 1 if($0 ~ /[ \t]$/ || $0 !~ /^[^ ]+ [^ ]+( [^ ].*)?$/ || NF<2) exit 1 if($1!="ssh-ed25519" && $1!="ssh-rsa" && $1!="ecdsa-sha2-nistp256" && $1!="ecdsa-sha2-nistp384" && $1!="ecdsa-sha2-nistp521" && $1!="sk-ssh-ed25519@openssh.com" && $1!="sk-ecdsa-sha2-nistp256@openssh.com") exit 1 if($2 !~ /^[A-Za-z0-9+\/]+={0,2}$/) { @@ -377,6 +499,8 @@ sync_keys() { [ "$bits" -ge 2048 ] || fail 'RSA keys must be at least 2048 bits.' ;; esac done < "$payload" +} +apply_payload() { [ -d "$ssh_dir" ] || { mkdir "$ssh_dir"; created_ssh=yes; } # Existing secure directory permissions need not be changed in dry-run mode. if [ "$dry_run" = no ]; then chmod 700 "$ssh_dir"; fi @@ -478,30 +602,604 @@ edit_sshd_config() { [ "$root_password" != yes ] || say 'PermitRootLogin yes does not enable PasswordAuthentication if it is disabled elsewhere.' } +# All persistent policy/state is a data format with a strict parser, never shell code. +validate_url() { + [ "${#1}" -le 2048 ] || return 1 + printf '%s\n' "$1" | grep -Eq '^https://([A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?|\[[0-9A-Fa-f:]+\])(:[0-9]{1,5})?(/[!-~]*)?$' || return 1 + case $1 in *'?'*|*'#'*|*'\'*) return 1 ;; esac +} +strict_text() { + last_lf "$1" || fail 'Local data must end with LF.' + new_temp "$state"; invalid_local=$TEMP + tr -d '\012\040-\176' < "$1" > "$invalid_local" + [ ! -s "$invalid_local" ] || fail 'Local data contains non-ASCII or control bytes.' +} +validate_urls() { + [ -s "$1" ] || fail 'At least one source URL is required.' + [ "$(wc -l < "$1" | tr -d ' ')" -le 6 ] || fail 'At most six source URLs (primary plus five fallbacks) are allowed.' + while IFS= read -r source_url; do + validate_url "$source_url" || fail 'Invalid HTTPS source URL; query strings, fragments and credentials are unsupported.' + done < "$1" + awk 'seen[$0]++ {exit 1}' "$1" || fail 'Duplicate source URL.' +} +load_local_config() { + policy=off + list_id=- + pinned_fingerprint=- + pin_mode=primary + gpg_email=- + keyservers="hkps://keys.openpgp.org +hkps://keyserver.ubuntu.com" + config_snapshot=absent + new_temp "$state"; urls=$TEMP + printf '%s/ssh.keys\n' "$BASE_URL" > "$urls" + if [ -f "$local_config" ]; then + new_temp "$state"; config_snapshot=$TEMP + cp "$local_config" "$config_snapshot" + [ "$(wc -c < "$config_snapshot" | tr -d ' ')" -le 40000 ] || fail 'Local configuration is too large.' + strict_text "$config_snapshot" + awk ' + NR==1 { if($0!="conf-sshd-local-v1") exit 1 } + NR==2 { if($0!="policy off" && $0!="policy required") exit 1; p=$2 } + NR==3 { if($0!="list-id " $2 || $2 ~ /[^A-Za-z0-9._-]/ || length($2)<1 || length($2)>64) exit 1; l=$2 } + NR==4 { if($0!="fingerprint " $2) exit 1; f=$2 + if(p=="required" && (length(f)!=40 || f ~ /[^0-9A-F]/ || l=="-")) exit 1 + if(p=="off" && !((f=="-" && l=="-") || (length(f)==40 && f !~ /[^0-9A-F]/ && l!="-"))) exit 1 } + NR==5 {if($0!="pin-mode primary" && $0!="pin-mode subkey") exit 1; if(f=="-" && $2!="primary") exit 1} + NR==6 { if($0!="email " $2 || $2=="") exit 1 } + NR>6 { if(substr($0,1,4)=="url ") {u++; if(k) exit 1} else if(substr($0,1,10)=="keyserver ") k++; else exit 1 } + END { if(NR<8 || NR>16 || u<1 || u>6 || k<1 || k>4) exit 1 } + ' "$config_snapshot" || fail 'Malformed local configuration; refusing to guess or downgrade.' + policy=$(sed -n '2s/^policy //p' "$config_snapshot") + list_id=$(sed -n '3s/^list-id //p' "$config_snapshot") + pinned_fingerprint=$(sed -n '4s/^fingerprint //p' "$config_snapshot") + pin_mode=$(sed -n '5s/^pin-mode //p' "$config_snapshot") + gpg_email=$(sed -n '6s/^email //p' "$config_snapshot") + keyservers=$(sed -n '7,$s/^keyserver //p' "$config_snapshot") + sed -n '7,$s/^url //p' "$config_snapshot" > "$urls" + elif [ -e "$replay_state" ] || [ -e "$pending_state" ] || [ -e "$pending_bundle" ]; then + fail 'Local policy is missing while security state exists; refusing an implicit downgrade.' + fi + validate_urls "$urls" + validate_discovery_settings + if [ "$policy" = required ] && [ "$mode" != uninstall ] && [ "$mode" != config ] && [ "$cron_value" != false ] && [ "$requested_verification" != off ]; then + trust_key=$state/trust-$pinned_fingerprint-$pin_mode.asc + check_path "$trust_key" file + [ -f "$trust_key" ] || [ "$mode" = install ] || [ "$trust_options" = yes ] || fail 'Dedicated pinned certificate is missing; reinstall explicitly to recover it.' + [ -f "$replay_state" ] || fail 'Rollback state is missing after enrollment; refusing silent bootstrap.' + fi +} +new_gpg_home() { + need gpg + if [ -z "$gpg_home" ]; then + gpg_home=$(mktemp -d "$state/.conf-sshd-gpg.XXXXXX") || fail 'Cannot create isolated GnuPG directory.' + chmod 700 "$gpg_home" + fi +} +gpg_local() { + gpg --batch --no-options --homedir "$gpg_home" --no-tty --no-autostart \ + --no-auto-key-retrieve --auto-key-locate clear --no-default-keyring \ + --keyring "$gpg_home/pinned.gpg" "$@" +} +import_pinned_key() { + new_gpg_home + [ "$(wc -c < "$1" | tr -d ' ')" -le 1048576 ] || fail 'Public key file exceeds 1 MiB.' + new_temp "$state"; key_description=$TEMP + gpg_local --with-colons --show-keys "$1" > "$key_description" 2>/dev/null || fail 'Cannot inspect local OpenPGP public key.' + current_time=$(date +%s) + temps="$temps +$key_description.primary" + awk -F: -v pin="$pinned_fingerprint" -v mode="$pin_mode" -v now="$current_time" ' + $1=="sec" || $1=="ssb" { bad=1 } + $1=="pub" || $1=="sub" { + kind=$1; valid=($2 !~ /[redi]/ && ($7=="" || $7+0>now)); signing=($12 ~ /[sS]/) + if(kind=="pub" && (++p!=1 || !valid)) bad=1 + want=1; next + } + $1=="fpr" && want { + if(kind=="pub") primary=$10 + if($10==pin && ((mode=="primary" && kind=="pub") || (mode=="subkey" && kind=="sub"))) {matched++; if(!valid || !signing) bad=1} + want=0 + } + END {if(bad || p!=1 || matched!=1 || length(primary)!=40 || primary ~ /[^0-9A-F]/) exit 1; print primary} + ' "$key_description" > "$key_description.primary" || fail 'Certificate must contain exactly one current primary and the expected current primary/subkey fingerprint, with no secret keys.' + certificate_primary=$(cat "$key_description.primary") + gpg_local --yes --dearmor --output "$gpg_home/pinned.gpg" "$1" >/dev/null 2>&1 || fail 'Cannot prepare isolated public-only certificate keyring.' +} +validate_discovery_settings() { + if [ "$gpg_email" != - ]; then + [ "${#gpg_email}" -le 254 ] && printf '%s\n' "$gpg_email" | grep -Eq '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,63}$' || fail 'Invalid WKD email address.' + fi + new_temp "$state"; server_list=$TEMP + printf '%s\n' "$keyservers" > "$server_list" + [ "$(wc -l < "$server_list" | tr -d ' ')" -le 4 ] || fail 'At most four keyservers are permitted.' + while IFS= read -r server; do + printf '%s\n' "$server" | grep -Eq '^hkps://[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?(:[0-9]{1,5})?$' || fail 'Keyservers must be explicit HKPS origins.' + done < "$server_list" +} +reset_gpg_home() { + if [ -n "$gpg_home" ]; then + # Kill only services belonging to our private transient home, if any. + if command -v gpgconf >/dev/null 2>&1; then gpgconf --homedir "$gpg_home" --kill all >/dev/null 2>&1 || :; fi + rm -rf "$gpg_home" + gpg_home= + fi + new_gpg_home +} +accept_certificate_candidate() { + # A separate clean keyring per discovery candidate prevents unrelated keys + # or a rejected certificate from contaminating the final verifier. + reset_gpg_home + new_temp "$state"; candidate_export=$TEMP + if ( lock_owned=no; created_state=no; created_ssh=no; temps=; + trap 'candidate_result=$?; gpg_home=; cleanup "$candidate_result"' 0 + import_pinned_key "$1" + gpg_local --armor --export "$certificate_primary" > "$candidate_export" || fail 'Candidate certificate cannot be exported by this GnuPG version.' + [ -s "$candidate_export" ] && [ "$(wc -c < "$candidate_export" | tr -d ' ')" -le 1048576 ] || fail 'Candidate public certificate export is empty or oversized.' ); then + import_pinned_key "$candidate_export" + staged_trust=$candidate_export + trust_key=$state/trust-$pinned_fingerprint-$pin_mode.asc + check_path "$trust_key" file + return 0 + fi + return 1 +} +lookup_bounded() { + now=$(date +%s) + remaining=$((discovery_deadline - now)) + [ "$remaining" -gt 0 ] || return 1 + if [ "$remaining" -gt 15 ]; then remaining=15; fi + if command -v timeout >/dev/null 2>&1; then timeout -k 2 "$remaining" "$@" + elif command -v gtimeout >/dev/null 2>&1; then gtimeout -k 2 "$remaining" "$@" + else say 'Skipping WKD/keyserver lookup: a timeout or gtimeout utility is required.' >&2; return 1; fi +} +discover_certificate() { + discovery_deadline=$(($(date +%s) + 120)) + new_temp "$state"; candidate=$TEMP + if [ -n "$public_key_file" ]; then + check_path "$public_key_file" file + [ -f "$public_key_file" ] || fail 'Local public key file does not exist.' + accept_certificate_candidate "$public_key_file" || fail 'Explicit certificate failed validation; refusing to fall back to a possibly stale certificate.' + return + fi + existing_trust=$state/trust-$pinned_fingerprint-$pin_mode.asc + check_path "$existing_trust" file + if [ -f "$existing_trust" ]; then + accept_certificate_candidate "$existing_trust" || fail 'Existing dedicated certificate is expired, revoked or invalid; supply an independently verified replacement explicitly.' + return + fi + ambient_home=${GNUPGHOME:-$HOME/.gnupg} + if [ -d "$ambient_home" ]; then + # Read/export only: no import, trust edits or remote lookup in this home. + if gpg --batch --no-options --homedir "$ambient_home" --no-tty --no-autostart \ + --no-auto-key-retrieve --auto-key-locate clear --no-auto-check-trustdb \ + --lock-never --export "$pinned_fingerprint" > "$candidate" 2>/dev/null && [ -s "$candidate" ]; then + if accept_certificate_candidate "$candidate"; then return; fi + fi + fi + if download "$BASE_URL/public-key.gpg" "$candidate" 1048576; then + if accept_certificate_candidate "$candidate"; then return; fi + fi + if [ "$gpg_email" != - ]; then + reset_gpg_home + if lookup_bounded gpg --batch --no-options --homedir "$gpg_home" --no-tty \ + --no-auto-key-retrieve --auto-key-locate clear,wkd --locate-external-keys -- "$gpg_email" >/dev/null 2>&1; then + gpg --batch --no-options --homedir "$gpg_home" --no-tty --no-autostart --no-auto-key-retrieve --auto-key-locate clear --armor --export > "$candidate" 2>/dev/null || : + if accept_certificate_candidate "$candidate"; then return; fi + fi + fi + while IFS= read -r server; do + reset_gpg_home + if lookup_bounded gpg --batch --no-options --homedir "$gpg_home" --no-tty \ + --no-auto-key-retrieve --auto-key-locate clear --keyserver "$server" \ + --recv-keys "$pinned_fingerprint" >/dev/null 2>&1; then + gpg --batch --no-options --homedir "$gpg_home" --no-tty --no-autostart --no-auto-key-retrieve --auto-key-locate clear --armor --export > "$candidate" 2>/dev/null || : + if accept_certificate_candidate "$candidate"; then return; fi + fi + done < "$server_list" + fail 'No local, Worker, WKD or configured keyserver certificate matched the complete trusted pin; nothing installed.' +} +read_replay_state() { + strict_text "$1" + [ "$(wc -c < "$1" | tr -d ' ')" -le 256 ] || fail 'Rollback state is oversized.' + awk -v list="$list_id" ' + NR==1 { if($0!="conf-sshd-state-v1") exit 1 } + NR==2 { if($0!="list-id " list) exit 1 } + NR==3 { if($0!="sequence " $2 || $2 !~ /^(0|[1-9][0-9]*)$/ || length($2)>10 || $2+0>2147483647) exit 1 } + NR==4 { if($0!="sha256 " $2 || ($2!="-" && (length($2)!=64 || $2 ~ /[^0-9a-f]/))) exit 1 } + END { if(NR!=4) exit 1 } + ' "$1" || fail 'Malformed rollback state; refusing silent bootstrap.' + recorded_sequence=$(sed -n '3s/^sequence //p' "$1") + recorded_digest=$(sed -n '4s/^sha256 //p' "$1") +} +write_replay_record() { + printf 'conf-sshd-state-v1\nlist-id %s\nsequence %s\nsha256 %s\n' "$list_id" "$1" "$2" > "$3" +} +confirm_policy_change() { + say "Signature policy change: $1; fingerprint $2; pin mode $3; list $4." >&2 + if [ "$3" = primary ]; then say 'Primary pin trusts valid, bound signing subkeys of that primary.' >&2 + else say 'Subkey pin trusts only that exact signing subkey, not its siblings.' >&2; fi + say 'If local/Worker discovery fails, optional WKD and configured keyservers may receive the email/fingerprint and your IP.' >&2 + if [ "$dry_run" = yes ]; then fail 'Trust-changing dry runs do not enroll, query certificates or prompt; rerun locally without --dry-run to review and consent.'; fi + if ! ( : < /dev/tty ) 2>/dev/null; then fail 'Trust changes require confirmation in a local terminal (/dev/tty); no noninteractive bypass exists.'; fi + printf 'To confirm %s, type the complete fingerprint %s: ' "$1" "$2" > /dev/tty + IFS= read -r confirmation < /dev/tty || fail 'Trust confirmation was not received.' + [ "$confirmation" = "$2" ] || fail 'Trust change was not confirmed.' +} +require_explicit_installer() { + if [ "$mode" != install ] && [ -f "$target_script" ]; then + [ "$(grep -Fxc '# conf-sshd-client-security-v1' "$target_script" || :)" = 1 ] || fail 'Existing scheduled script predates signature enforcement; use --install to upgrade it before changing trust.' + fi + # --install or explicit trust options are a user-triggered installation; + # scheduled -o without options never enters this policy-change flow. + say 'Installer bootstrap code is trusted by this invocation. Independently checking a local script hash is recommended.' >&2 +} +prepare_install_config() { + config_prepared=yes + staged_replay= + replay_snapshot=absent + staged_trust= + old_policy=$policy + old_pin=$pinned_fingerprint + old_pin_mode=$pin_mode + old_list=$list_id + if [ "$config_snapshot" = absent ]; then + requested_verification=${requested_verification:-$DEFAULT_GPG_VERIFY} + requested_fingerprint=${requested_fingerprint:-$DEFAULT_GPG_FINGERPRINT} + requested_pin_mode=${requested_pin_mode:-$DEFAULT_GPG_PIN_MODE} + requested_list_id=${requested_list_id:-$DEFAULT_LIST_ID} + requested_email=${requested_email:-$DEFAULT_GPG_EMAIL} + fi + if [ -n "$requested_email" ]; then gpg_email=$requested_email; fi + if [ -n "$requested_keyservers" ]; then keyservers=$(printf '%s' "$requested_keyservers"); fi + validate_discovery_settings + if [ "$requested_verification" = off ]; then + if [ "$old_policy" = required ]; then + require_explicit_installer + confirm_policy_change disable "$old_pin" "$old_pin_mode" "$old_list" + fi + policy=off + # Preserve the enrolled pin and rollback state for any future re-enable. + if [ "$old_pin" = - ]; then pinned_fingerprint=-; list_id=-; pin_mode=primary; fi + elif [ "$requested_verification" = on ] || [ -n "$public_key_file$requested_fingerprint$requested_list_id$requested_min_sequence$requested_pin_mode" ]; then + require_explicit_installer + if [ "$old_pin" != - ]; then + requested_fingerprint=${requested_fingerprint:-$old_pin} + requested_list_id=${requested_list_id:-$old_list} + fi + [ -n "$requested_fingerprint" ] && [ -n "$requested_list_id" ] || fail 'Enabling verification requires --gpg-fingerprint and --list-id (or configured defaults).' + requested_pin_mode=${requested_pin_mode:-$pin_mode} + case $requested_pin_mode in primary|subkey) : ;; *) fail '--gpg-pin-mode must be primary or subkey.' ;; esac + requested_fingerprint=$(printf '%s' "$requested_fingerprint" | tr 'a-f' 'A-F') + printf '%s\n' "$requested_fingerprint" | grep -Eq '^[0-9A-F]{40}$' || fail 'Expected a full 40-hex fingerprint.' + printf '%s\n' "$requested_list_id" | grep -Eq '^[A-Za-z0-9._-]{1,64}$' || fail 'Invalid signed list ID.' + [ "$requested_list_id" != - ] || fail 'List ID cannot be a dash.' + if [ "$old_pin" != - ]; then + [ "$requested_list_id" = "$old_list" ] || fail 'Reinstall cannot reset the enrolled list identity or rollback history.' + [ -f "$replay_state" ] || fail 'Rollback state is missing after enrollment; refusing silent bootstrap.' + fi + confirm_policy_change enable/change "$requested_fingerprint" "$requested_pin_mode" "$requested_list_id" + policy=required + list_id=$requested_list_id + pinned_fingerprint=$requested_fingerprint + pin_mode=$requested_pin_mode + need date + discover_certificate + if [ "$old_pin" != - ] && [ -n "$requested_min_sequence" ]; then + floor=$requested_min_sequence + printf '%s\n' "$floor" | grep -Eq '^[1-9][0-9]{0,9}$' && [ "$floor" -le 2147483647 ] || fail 'Minimum sequence must be 1..2147483647.' + new_temp "$state"; replay_snapshot=$TEMP + cp "$replay_state" "$replay_snapshot" + read_replay_state "$replay_snapshot" + [ "$floor" -ge "$recorded_sequence" ] || fail 'Explicit minimum sequence cannot lower the persisted rollback boundary.' + new_temp "$state"; staged_replay=$TEMP + if [ "$floor" -gt "$recorded_sequence" ]; then write_replay_record "$((floor - 1))" - "$staged_replay" + else cp "$replay_snapshot" "$staged_replay"; fi + fi + if [ "$old_pin" = - ]; then + [ ! -e "$replay_state" ] && [ ! -e "$pending_state" ] && [ ! -e "$pending_bundle" ] || fail 'Existing security state requires manual recovery; cannot silently enroll over it.' + floor=${requested_min_sequence:-1} + printf '%s\n' "$floor" | grep -Eq '^[1-9][0-9]{0,9}$' && [ "$floor" -le 2147483647 ] || fail 'Minimum sequence must be 1..2147483647.' + new_temp "$state"; staged_replay=$TEMP + write_replay_record "$((floor - 1))" - "$staged_replay" + fi + fi + if [ "$policy" = required ] && [ -z "$staged_trust" ]; then + require_explicit_installer + confirm_policy_change reinstall/refresh "$pinned_fingerprint" "$pin_mode" "$list_id" + discover_certificate + fi + if [ -n "$primary_url" ] || [ -n "$fallback_urls_file" ]; then + new_temp "$state"; new_urls=$TEMP + if [ -n "$primary_url" ]; then printf '%s\n' "$primary_url" > "$new_urls" + else sed -n '1p' "$urls" > "$new_urls"; fi + if [ -n "$fallback_urls_file" ]; then + check_path "$fallback_urls_file" file + [ -f "$fallback_urls_file" ] || fail 'Fallback URL file does not exist.' + [ "$(wc -c < "$fallback_urls_file" | tr -d ' ')" -le 35000 ] || fail 'Fallback URL file is too large.' + if [ -s "$fallback_urls_file" ]; then strict_text "$fallback_urls_file"; fi + cat "$fallback_urls_file" >> "$new_urls" + elif [ -n "$primary_url" ]; then sed -n '2,$p' "$urls" >> "$new_urls"; fi + urls=$new_urls + validate_urls "$urls" + fi + new_temp "$state"; staged_config=$TEMP + printf 'conf-sshd-local-v1\npolicy %s\nlist-id %s\nfingerprint %s\npin-mode %s\nemail %s\n' "$policy" "$list_id" "$pinned_fingerprint" "$pin_mode" "$gpg_email" > "$staged_config" + sed 's/^/url /' "$urls" >> "$staged_config" + sed 's/^/keyserver /' "$server_list" >> "$staged_config" +} +commit_install_config() { + if [ -n "$staged_trust" ]; then replace_file "$trust_key" "$staged_trust" 600; fi + if [ -n "$staged_replay" ]; then replace_file "$replay_state" "$staged_replay" 600 "$replay_snapshot"; fi + replace_file "$local_config" "$staged_config" 600 "$config_snapshot" +} +validate_signed_document() { + last_lf "$response" || fail 'Signed document must end with LF.' + [ "$(wc -c < "$response" | tr -d ' ')" -le 1048576 ] || fail 'Signed document exceeds 1 MiB.' + new_temp "$state"; invalid_bytes=$TEMP + tr -d '\012\040-\176' < "$response" > "$invalid_bytes" + [ ! -s "$invalid_bytes" ] || fail 'Signed document contains noncanonical bytes.' + new_temp "$state"; metadata=$TEMP + awk ' + NR==1 { if($0!="# conf-sshd-signed-v1") exit 1 } + NR==2 { if($0!="# list-id " $3 || $3 ~ /[^A-Za-z0-9._-]/ || length($3)<1 || length($3)>64) exit 1; print $3 } + NR==3 { if($0!="# sequence " $3 || $3 !~ /^[1-9][0-9]*$/ || length($3)>10 || $3+0>2147483647) exit 1; print $3 } + NR==4 || NR==5 { label=(NR==4 ? "issued-at" : "expires-at") + if($0!="# " label " " $3 || $3 !~ /^(0|[1-9][0-9]*)$/ || length($3)>10) exit 1 + if(NR==4) issue=$3+0; else if($3+0<=issue) exit 1; print $3 } + NR==6 { if($0!="# revoke yes" && $0!="# revoke no") exit 1; revoke=$3; print $3 } + NR==7 { if($0!="# key-count " $3 || $3 !~ /^(0|[1-9][0-9]*)$/ || length($3)>4 || $3+0>1000) exit 1 + if(($3==0 && revoke!="yes") || ($3!=0 && revoke!="no")) exit 1; print $3 } + NR==8 { if($0!="# end-metadata") exit 1 } + END { if(NR<8) exit 1 } + ' "$response" > "$metadata" || fail 'Invalid signed-document schema or revocation metadata.' + release_list=$(sed -n '1p' "$metadata") + sequence=$(sed -n '2p' "$metadata") + issued_at=$(sed -n '3p' "$metadata") + expires_at=$(sed -n '4p' "$metadata") + key_count=$(sed -n '6p' "$metadata") + revision=$sequence + new_temp "$state"; payload=$TEMP + tail -n +9 "$response" > "$payload" + [ "$(wc -l < "$payload" | tr -d ' ')" = "$key_count" ] || fail 'Signed payload key count mismatch.' + # Signed documents are canonical even when signature verification is OFF. + awk 'NF!=2 || $0!=$1 " " $2 {exit 1}' "$payload" || fail 'Signed document key lines must be comment-free and canonical.' + if [ "$policy" = required ]; then + [ "$release_list" = "$list_id" ] || fail 'Signed list identity does not match local enrollment.' + now=$(date +%s) + if [ "$enforce_freshness" = yes ]; then + [ "$issued_at" -le "$((now + 300))" ] && [ "$expires_at" -gt "$now" ] || fail 'Signed release is expired or issued too far in the future.' + fi + fi + digest=$(sha256 "$response") + validate_payload +} +verify_signature() { + last_lf "$signature" || fail 'Detached signature must end with LF.' + [ "$(wc -c < "$signature" | tr -d ' ')" -le 65536 ] || fail 'Detached signature exceeds 64 KiB.' + new_temp "$state"; bad_signature=$TEMP + tr -d '\012\040-\176' < "$signature" > "$bad_signature" + [ ! -s "$bad_signature" ] || fail 'Detached signature must be ASCII armor with LF line endings.' + awk ' + NR==1 {if($0!="-----BEGIN PGP SIGNATURE-----") exit 1} + { if(ended) exit 1; if($0=="-----END PGP SIGNATURE-----") ended=1 } + END {if(!ended) exit 1} + ' "$signature" || fail 'Malformed detached-signature armor.' + new_temp "$state"; gpg_status=$TEMP + gpg_local --status-fd 1 --verify "$signature" "$response" > "$gpg_status" 2>/dev/null || fail 'Detached signature verification failed.' + awk -v pin="$pinned_fingerprint" -v mode="$pin_mode" -v expectedprimary="$certificate_primary" -v now="$(date +%s)" ' + $1!="[GNUPG:]" {bad=1} + $2 ~ /^(BADSIG|ERRSIG|NO_PUBKEY|EXPSIG|EXPKEYSIG|REVKEYSIG|KEYEXPIRED|KEYREVOKED|SIGEXPIRED|BADARMOR|FAILURE|ERROR|NODATA|UNEXPECTED)$/ {bad=1} + $2=="VALIDSIG" { good++; primary=$12 + if(NF!=12 || $7!=4 || $11!="00" || length(primary)!=40 || primary ~ /[^0-9A-F]/ || primary!=expectedprimary || (mode=="primary" && primary!=pin) || (mode=="subkey" && $3!=pin) || length($3)!=40 || $3 ~ /[^0-9A-F]/ || ($10!=8 && $10!=9 && $10!=10)) bad=1 + if($5 !~ /^(0|[1-9][0-9]*)$/ || length($5)>10 || $5+0>now+300 || $6 !~ /^(0|[1-9][0-9]*)$/ || length($6)>10 || ($6!=0 && $6+0<=now)) bad=1 } + END {if(bad || good!=1) exit 1} + ' "$gpg_status" || fail 'Signature status lacks one valid, unexpired signature from the locally pinned primary/subkey.' +} +check_replay() { + [ "$sequence" -ge "$recorded_sequence" ] || fail 'Signed sequence rollback rejected.' + if [ "$sequence" = "$recorded_sequence" ]; then + [ "$digest" = "$recorded_digest" ] || fail 'Signed sequence equivocation rejected (same sequence, different bytes).' + fi +} +validate_response() { + if [ "$policy" = required ]; then + verify_signature + validate_signed_document + check_replay + elif [ "$(sed -n '1p' "$response")" = '# conf-sshd-signed-v1' ]; then + validate_signed_document + else validate_legacy_response; fi +} +# Journal pairs are local only; transport is /ssh.keys plus /ssh.keys.sig. +make_pending_bundle() { + cat "$response" > "$1" + printf '# detached-signature\n' >> "$1" + cat "$signature" >> "$1" +} +read_pending_bundle() { + [ "$(wc -c < "$pending_bundle" | tr -d ' ')" -le 1114133 ] || fail 'Pending package is oversized.' + new_temp "$state"; response=$TEMP + new_temp "$state"; signature=$TEMP + # Marker is forbidden by the strict document schema and signature armor. + awk -v sig="$signature" ' + $0=="# detached-signature" {if(found++) exit 1; next} + {if(found) print > sig; else print} + END {if(found!=1) exit 1} + ' "$pending_bundle" > "$response" || fail 'Malformed pending package.' + # Enforce original bytes: awk must not normalize a malformed journal. + new_temp "$state"; rebuilt=$TEMP + make_pending_bundle "$rebuilt" + cmp -s "$rebuilt" "$pending_bundle" || fail 'Noncanonical pending package.' +} +finish_verified_release() { + # Commit a complete verified package before advancing any anti-rollback state + # or replacing keys. Atomic renames survive process interruption, not every + # power-loss/filesystem failure; no portable fsync promise is made here. + if [ "$dry_run" = no ]; then + if [ "$replace_pending" = yes ]; then + # Preserve the authenticated old journal floor before superseding its + # files. A crash at any step can only retain/raise this boundary. + new_temp "$state"; promoted_state=$TEMP + write_replay_record "$recorded_sequence" "$recorded_digest" "$promoted_state" + replace_file "$replay_state" "$promoted_state" 600 + rm -f "$pending_state" "$pending_bundle" + fi + if [ ! -f "$pending_state" ]; then + new_temp "$state"; pending_tmp=$TEMP + make_pending_bundle "$pending_tmp" + replace_file "$pending_bundle" "$pending_tmp" 600 + new_temp "$state"; pending_record=$TEMP + write_replay_record "$sequence" "$digest" "$pending_record" + replace_file "$pending_state" "$pending_record" 600 absent + fi + fi + apply_payload + if [ "$dry_run" = no ]; then + new_temp "$state"; final_state=$TEMP + write_replay_record "$sequence" "$digest" "$final_state" + replace_file "$replay_state" "$final_state" 600 + rm -f "$pending_state" "$pending_bundle" + fi +} +sync_keys() { + need ssh-keygen + validate_build + if [ "$policy" = required ]; then + need date + if [ -n "${staged_replay:-}" ]; then read_replay_state "$staged_replay" + else read_replay_state "$replay_state"; fi + if [ -z "$gpg_home" ]; then import_pinned_key "$trust_key"; fi + if [ -f "$pending_state" ] || [ -f "$pending_bundle" ]; then + saved_sequence=$recorded_sequence + saved_digest=$recorded_digest + pending_sequence= + pending_digest= + if [ -f "$pending_state" ]; then + read_replay_state "$pending_state" + pending_sequence=$recorded_sequence + pending_digest=$recorded_digest + [ "$pending_digest" != - ] || fail 'Pending state must identify an already verified document.' + [ "$pending_sequence" -ge "$saved_sequence" ] || fail 'Pending rollback record is below the committed boundary.' + if [ "$pending_sequence" = "$saved_sequence" ]; then + [ "$pending_digest" = "$saved_digest" ] || fail 'Pending rollback record conflicts with committed history.' + fi + fi + recorded_sequence=$saved_sequence + recorded_digest=$saved_digest + if [ -f "$pending_bundle" ]; then + read_pending_bundle + enforce_freshness=no + validate_signed_document + enforce_freshness=yes + check_replay + if [ -n "$pending_sequence" ]; then + [ "$sequence" = "$pending_sequence" ] && [ "$digest" = "$pending_digest" ] || fail 'Pending package does not match its rollback record.' + elif [ "$sequence" = "$recorded_sequence" ] && [ "$digest" = "$recorded_digest" ]; then + # Crash while removing an old journal after promoting its + # floor: committed state already authenticates these bytes. + pending_sequence=$sequence + pending_digest=$digest + fi + if ( lock_owned=no; created_state=no; created_ssh=no; temps=; + trap 'child_result=$?; gpg_home=; cleanup "$child_result"' 0 + validate_response ); then + if [ "$validation_only" = no ]; then + finish_verified_release + say 'Recovered the same verified pending release; no fallback was fetched.' + fi + return + fi + [ -n "$pending_sequence" ] || fail 'Uncommitted pending package is no longer verifiable; manual recovery is required.' + else + [ -n "$pending_sequence" ] || fail 'Pending package/state is incomplete.' + fi + # The protected committed journal records a previously verified + # release. Its current expiry/revocation never authorizes installing + # it, but its sequence/digest still prevent rollback while fetching + # a fresh valid release under the currently enrolled pin. + recorded_sequence=$pending_sequence + recorded_digest=$pending_digest + replace_pending=yes + say 'Pending release cannot currently be applied; retaining its rollback floor while fetching a fresh valid release.' >&2 + fi + fi + new_temp "$state"; response=$TEMP + new_temp "$state"; signature=$TEMP + accepted=no + # Full validation occurs per source. A failed mirror never permits a policy + # downgrade; candidate-local temporaries are cleaned even when fail() exits. + while IFS= read -r source_url; do + if download "$source_url" "$response" 1048576; then + if [ "$policy" = required ] && ! download "$source_url.sig" "$signature" 65536; then + say "Signature unavailable from $source_url; trying next locally configured source." >&2 + continue + fi + if ( lock_owned=no; created_state=no; created_ssh=no; gpg_home_saved=$gpg_home; gpg_home=; temps=; + # Preserve the caller keyring but never delete it in this subshell. + gpg_home=$gpg_home_saved + trap 'child_result=$?; gpg_home=; cleanup "$child_result"' 0 + validate_response ); then accepted=yes; break; fi + fi + say "Rejected source $source_url; trying next locally configured source." >&2 + done < "$urls" + [ "$accepted" = yes ] || fail 'No locally configured source passed the required policy; existing keys are unchanged.' + validate_response + if [ "$validation_only" = no ]; then + if [ "$policy" = required ]; then finish_verified_release; else apply_payload; fi + fi +} + +if [ "$mode" = uninstall ] || [ "$mode" = config ] || { [ "$mode" = sync ] && [ "$cron_requested" = yes ] && [ "$cron_value" = false ]; }; then + policy=off + config_snapshot=absent +else load_local_config; fi case $mode in - update) + install) validate_build - [ -f "$target_script" ] || fail 'No installed script. Install scheduling with -c first.' - new_temp "$state"; updated_script=$TEMP - download "$BASE_URL/script.sh" "$updated_script" - validate_script "$updated_script" - replace_file "$target_script" "$updated_script" 700 - say 'Script update validated. Keys, cron and sshd configuration were not changed.' ;; + prepare_install_config + prepare_script + if [ "$cron_requested" = yes ]; then + prepare_cron + if [ -z "$cron_value" ]; then cron_value=${existing_schedule:-$DEFAULT_CRON}; fi + fi + validation_only=yes + sync_keys + # Activate policy only after replacing any legacy scheduled executable. + install_script + commit_install_config + if [ "$policy" = required ]; then finish_verified_release; else apply_payload; fi + if [ "$cron_requested" = yes ]; then apply_cron "$cron_value"; fi ;; uninstall) if command -v crontab >/dev/null 2>&1; then prepare_cron; apply_cron false; fi for uninstall_file in "$target_script" "$target_script.conf-sshd.bak"; do check_path "$uninstall_file" file if [ "$dry_run" = yes ]; then say "Would remove $uninstall_file"; else rm -f "$uninstall_file"; fi done - say 'Uninstalled managed scheduling/script. Authorized keys and sshd configuration are preserved.' ;; + say 'Uninstalled managed scheduling/script. Authorized keys, trust and rollback state are preserved.' ;; config) edit_sshd_config ;; sync|keys) + script_tmp= if [ "$cron_requested" = yes ]; then prepare_cron if [ "$cron_value" = false ]; then apply_cron false; exit 0; fi validate_build if [ -z "$cron_value" ]; then cron_value=${existing_schedule:-$DEFAULT_CRON}; fi fi + if [ "$trust_options" = yes ] || { [ "$config_snapshot" = absent ] && [ "$DEFAULT_GPG_VERIFY" = on ]; }; then + validate_build + prepare_install_config + validation_only=yes + fi + if [ "$cron_requested" = yes ] && [ ! -f "$target_script" ]; then prepare_script; fi sync_keys - if [ "$cron_requested" = yes ]; then install_script; apply_cron "$cron_value"; fi ;; + if [ "$config_prepared" = yes ]; then + if [ -n "$script_tmp" ]; then install_script; script_tmp=; fi + commit_install_config + if [ "$policy" = required ]; then finish_verified_release; else apply_payload; fi + fi + if [ "$cron_requested" = yes ]; then + if [ -n "$script_tmp" ]; then install_script; fi + apply_cron "$cron_value" + fi ;; esac diff --git a/deployment.json b/deployment.json index 3b759f9..4addff9 100644 --- a/deployment.json +++ b/deployment.json @@ -3,5 +3,15 @@ "repositoryUrl": "https://github.com/LamGC/quickly-conf-sshd", "defaultCron": "*/15 * * * *", "allowEmpty": false, - "fallbackSources": [] + "fallbackSources": [], + "keySource": { + "type": "git" + }, + "clientDefaults": { + "gpgVerification": "off", + "fingerprint": "", + "pinMode": "primary", + "listId": "", + "email": "" + } } diff --git a/scripts/build.mjs b/scripts/build.mjs index 2ffcd1b..5bf4444 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -7,20 +7,25 @@ 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, renderKeys, renderCommands, renderPage } from '../cf-worker/src/view.js'; -export { escapeHTML, commandsFor, renderKeys, renderCommands }; -import { validateFallbackSources, parsePublicKeys } from '../cf-worker/src/key-source.js'; +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'].includes(key)) throw Error(`Unknown configuration: ${key}`); + 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]]; @@ -37,6 +42,18 @@ export function validateConfig(config) { 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); @@ -54,8 +71,9 @@ export async function parseKeys(input) { 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 = keys.map(key => key.text + '\n').join(''); + 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.'); @@ -65,26 +83,62 @@ export function makeKeysText(keys, revision, allowEmpty) { 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 = await readFile(join(directory,testFixture ? 'tests/fixtures/keys.pub' : 'keys/authorized_keys'),'utf8'); - const keys = await parseKeys(input); + 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 needsFallback = keys.length === 0 && !config.allowEmpty && fallbackSources.length > 0; - const bundle = needsFallback ? {sha256:null,text:null} : makeKeysText(keys,revision,config.allowEmpty); + 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:config.allowEmpty,sha256:bundle.sha256,testFixture,baseUrl:config.baseUrl,repositoryUrl:config.repositoryUrl,fallbackSources,needsFallback,keys,commands:commandsFor(config)}; + 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}`; @@ -92,9 +146,9 @@ export async function build({directory = root, outDirectory = join(directory,'cf html = html.replaceAll(`/assets/${file}.${ext}`,`/assets/${name}`); } release.pageTemplate = html; - await writeFile(join(publicDir,'index.html'),renderPage(html,{...release,source:{type:needsFallback ? 'pending' : keys.length ? 'repository' : 'explicit-empty'}})); - await writeFile(join(generatedDir,'release.js'),`// Generated release; do not edit.\nexport default ${JSON.stringify({...release,script,keysText:bundle.text})};\n`); - await writeFile(join(generatedDir,'manifest.json'),JSON.stringify({...release,keys:undefined,commands:undefined,pageTemplate:undefined},null,2)+'\n'); + 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; } diff --git a/scripts/notify-deployment.mjs b/scripts/notify-deployment.mjs new file mode 100644 index 0000000..1bd9929 --- /dev/null +++ b/scripts/notify-deployment.mjs @@ -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; } +} diff --git a/scripts/prepare-key-release.mjs b/scripts/prepare-key-release.mjs new file mode 100644 index 0000000..c02b8b0 --- /dev/null +++ b/scripts/prepare-key-release.mjs @@ -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; } +} diff --git a/scripts/signature-tools.mjs b/scripts/signature-tools.mjs new file mode 100644 index 0000000..47d9ce9 --- /dev/null +++ b/scripts/signature-tools.mjs @@ -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()}; +} diff --git a/scripts/verify-commit-signature.mjs b/scripts/verify-commit-signature.mjs new file mode 100644 index 0000000..dbcd286 --- /dev/null +++ b/scripts/verify-commit-signature.mjs @@ -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; } +} diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index aa039f1..62a6dd6 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -1,29 +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'; -const manifest=JSON.parse(await readFile(new URL('../cf-worker/generated/manifest.json',import.meta.url),'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 verified: ${manifest.revision}`); } -else { - const timeout=()=>AbortSignal.timeout(20000); - const metadataResponse=await fetch(manifest.baseUrl+'/release.json',{cache:'no-store',signal:timeout()}); - if(!metadataResponse.ok) throw Error(`Metadata endpoint returned ${metadataResponse.status}`); - const metadata=await metadataResponse.json(); - 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.'); - if(manifest.needsFallback) { - if(metadata.source?.type!=='fallback'||!manifest.fallbackSources.includes(metadata.source.url)||metadata.keyCount<1)throw Error('Unexpected effective fallback source.'); - } else if(metadata.sha256!==manifest.sha256) throw Error('Remote repository keys differ from this build.'); - const keysResponse=await fetch(manifest.baseUrl+'/ssh.keys',{cache:'no-store',signal:timeout()}); - if(!keysResponse.ok) throw Error(`Keys endpoint returned ${keysResponse.status}`); - const keysText=await keysResponse.text(); - 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(keysText); - if(!match||match[1]!==manifest.revision)throw Error('Remote key manifest invalid.'); - const [, , count, empty, hash, payload]=match; - if(createHash('sha256').update(payload).digest('hex')!==hash) throw Error('Remote key body does not match its response digest.'); - const keys=await parsePublicKeys(payload); - if(keys.length!==Number(count)||(!keys.length&&(!manifest.allowEmpty||empty!=='yes')))throw Error('Remote key count/empty policy invalid.'); - if(!manifest.needsFallback&&hash!==manifest.sha256)throw Error('Remote repository key payload differs from this build.'); - // Dynamic fallback may legitimately change between the two requests; each response is checked independently. - console.log(`Remote code release verified: ${metadata.revision}; key payload ${hash} (${count} keys).`); +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)); } diff --git a/site/assets/app.js b/site/assets/app.js index 08e549b..15e022c 100644 --- a/site/assets/app.js +++ b/site/assets/app.js @@ -8,7 +8,6 @@ const dialog = document.getElementById("copy-fallback"); const fallbackText = document.getElementById("fallback-text"); const closeFallback = document.getElementById("close-copy-fallback"); - const allKeysButton = document.getElementById("copy-all-keys"); const validThemes = new Set(["system", "light", "dark"]); const resetTimers = new WeakMap(); let fallbackTrigger = null; @@ -115,23 +114,5 @@ button.addEventListener("click", () => copy(target.textContent, button)); }); - let keys = []; - try { - const release = JSON.parse(document.getElementById("release-data")?.textContent || "{}"); - if (Array.isArray(release.keys) && release.keys.every((key) => typeof key.text === "string" && key.text.length > 0)) { - keys = release.keys.map((key) => key.text); - } - } catch { - // Prerendered content remains usable if release metadata is unavailable. - } - if (keys.length === 0) { - keys = Array.from(document.querySelectorAll(".key-card pre code"), (code) => code.textContent).filter(Boolean); - } - if (allKeysButton) { - allKeysButton.disabled = keys.length === 0; - if (keys.length === 0) allKeysButton.setAttribute("title", "暂无可复制的公钥"); - allKeysButton.addEventListener("click", () => copy(`${keys.join("\n")}\n`, allKeysButton)); - } - root.classList.add("enhanced"); })(); diff --git a/site/assets/style.css b/site/assets/style.css index 7fb22fc..a18a4eb 100644 --- a/site/assets/style.css +++ b/site/assets/style.css @@ -103,19 +103,16 @@ h1 { margin: 0; font-size: clamp(30px, 3.4vw, 42px); font-weight: 650; letter-sp .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; align-items: center; gap: 18px; flex-shrink: 0; padding-bottom: 2px; } +.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; } -.key-list, .command-list { display: grid; gap: 14px; min-width: 0; } +.command-list { display: grid; gap: 14px; min-width: 0; } .command-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } -.key-card, .command-card { min-width: 0; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); overflow: hidden; } -.key-card { padding: 18px; } +.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; } -.key-type { flex-shrink: 0; max-width: 40%; overflow-wrap: anywhere; border: 1px solid var(--line); background: var(--surface-soft); border-radius: 4px; padding: 1px 7px; color: var(--muted); font: 10px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; } -.fingerprint { color: var(--subtle); font: 11px/1.65 ui-monospace, SFMono-Regular, Consolas, monospace; margin: 6px 0 12px; overflow-wrap: anywhere; } .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; } @@ -129,8 +126,6 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Libe .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; } -.copy-all { --icon-center: 19px; width: auto; gap: 8px; padding: 0 12px 0 38px; color: var(--accent); font-size: 12px; font-weight: 600; } -.copy-all:disabled { opacity: 0.55; } .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; } @@ -143,6 +138,9 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Libe .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; } @@ -204,16 +202,14 @@ dialog::backdrop { background: rgb(10 22 15 / 52%); } .section-heading h2 { font-size: 21px; } .section-actions { width: 100%; justify-content: space-between; } .commands-section > .section-heading > .text-link { margin-top: -3px; } - .key-card, .command-card { padding: 14px; } + .command-card { padding: 14px; } .command-list { grid-template-columns: minmax(0, 1fr); gap: 12px; } .card-heading { gap: 8px; } - .fingerprint { font-size: 10px; } 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; } - .copy-all { width: auto; } .public-notice { font-size: 11px; } .site-footer { flex-direction: column; align-items: flex-start; gap: 12px; padding: 22px 0 26px; } dialog { padding: 20px; } @@ -224,5 +220,5 @@ dialog::backdrop { background: rgb(10 22 15 / 52%); } .page-shell { width: 100%; } .hero { padding-block: 24px; } pre { white-space: pre-wrap; overflow-wrap: anywhere; } - .key-card, .command-card { break-inside: avoid; } + .command-card { break-inside: avoid; } } diff --git a/site/index.html b/site/index.html index 5175c61..d4a3056 100644 --- a/site/index.html +++ b/site/index.html @@ -4,13 +4,13 @@ - -SSH KEY DISTRIBUTION
核对公钥,选择命令,配置 SSH。
查看当前公钥来源与内容版本,按需部署。
查看发布状态,选择命令,配置 SSH。
查看当前公钥来源与内容版本,按需部署。
01 / PUBLIC KEYS
部署前,请核对指纹及密钥归属。
01 / RELEASE STATUS
首页仅展示来源、状态、数量与内容摘要,不展示公钥列表、指纹或注释。
@@KEYS_NOTICE@@
-这里展示的是公开密钥。私钥应始终由你保管,本页面不会要求上传或输入私钥。
公钥不是秘密:原始 API 与公开 Git / Gist 仍可能提供公钥内容。私钥应留在离线环境,本页面不会要求上传或输入私钥。
02 / QUICK START
以下命令会在你执行后修改服务器配置。请先检查脚本,并保留当前 SSH 会话以验证新连接。
02 / QUICK START
首次安装的快捷命令会立即执行下载脚本。推荐先检查并独立核验安装脚本;高信任安装可按下方指引操作。修改 SSH 配置时保留当前会话以验证新连接。
每条命令独立使用。高级选项涉及 SSH 登录策略或卸载,请阅读说明后操作。
+每条命令独立使用。同步、预览及高级命令须先安装。脚本一旦落盘,日常同步不会替换可执行代码或本地信任指纹;更新脚本须在服务器重新安装。
03 / LOCAL TRUST
签名文件存在,不代表 Worker 已验证签名。客户端验签默认关闭,须在服务器本地明确启用。