Skip to content

feat(macos): hy2 via a published SPKI pin for sing-box (0.0.74 blocker) - #668

Merged
raydocs merged 5 commits into
mainfrom
feat/macos-hy2-spki-pin-20260926
Sep 26, 2026
Merged

raydocs merged 5 commits into
mainfrom
feat/macos-hy2-spki-pin-20260926

Conversation

@raydocs

@raydocs raydocs commented Sep 26, 2026

Copy link
Copy Markdown
Owner

Owns: SHIP_PLAN G2 (连不上有下一手). Release 0.0.74 blocker. Owner decision 2026-09-26: hy2 must work for every user in this release, macOS included; fix the pin now (DECISIONS).

Problem

macOS 0.0.74 runs sing-box 1.15.0-alpha.3, which pins only tls.certificate_public_key_sha256 (base64 SHA-256 of the SubjectPublicKeyInfo). The catalog's hy2 fingerprint is the SHA-256 of the leaf DER (mihomo semantics), and a client cannot derive one from the other. Result: every hy2 node was TONO_SINGBOX_HY2_DER_PIN_UNSUPPORTED on macOS, so the backup channel did not exist there.

Change

  • Catalog key certificate-public-key-sha256 on hysteria2 blocks: standard base64 (with = padding) of the 32-byte SHA-256 of the leaf SubjectPublicKeyInfo DER. The operator computes it on the node from the same certificate as fingerprint, which stays mandatory. Operator command (use the file the hy2 config's tls.cert points at; script-installed nodes use /opt/tono-hy2/current/cert.pem):
    openssl x509 -in /opt/tono-hy2/tls/cert.pem -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64
    Checked locally: for a P-256 cert issued like manage-tono-hy2-node.sh, this equals Go x509.MarshalPKIXPublicKey + SHA-256 + base64.StdEncoding, which is what sing-box VerifyPublicKeySHA256 compares (pinned common/tls/std_client.go at 93fff595).
  • macOS: ConfigParser reads the key into ProxyNode.certificatePublicKeySHA256. validatedOwnedHysteria2 keeps it only as canonical base64 of 32 bytes (else nil). The DER fingerprint is still required. singBoxUnavailableReason makes hy2 available only with a valid pin. The builder emits tls: {enabled: true, server_name: <sni>, certificate_public_key_sha256: [<pin>]}, never insecure. Selection, the "试用备用通道" offer and route choices already filter on singBoxUnavailableReason, so a pinned hy2 node reaches them with no change there. isCityFailoverCandidate is unchanged: a TCP failure still never auto-switches onto hy2. The pin is part of liveSessionIdentity and CodingKeys. The mihomo YAML emitter does not emit it.
  • Control plane catalog-yaml.ts: hysteria2 blocks may carry the key once, as a plain block-style line, with a canonical 32-byte value. Anything else fails catalogProxyUsesManagedIdentity → INVALID_CATALOG (the same path as other malformed managed blocks). VLESS blocks must not carry it. Flow style is refused because macOS parsers from before this change merge an unknown flow key into the previous value and would refuse the whole catalog.
  • Publisher publish-managed-catalog.rb: no change needed (no key allowlist). A new test shows it keeps the line verbatim.
  • Windows: no change. tono-core RawProxy has no deny_unknown_fields and says unknown fields are dropped (node.rs:197-235). admit_hysteria2 still requires the DER fingerprint (node.rs:384-440). The mihomo runtime is built only from ValidatedNode::to_runtime_mapping (config.rs:952), which emits name/server/port/type/password/sni/fingerprint for hy2 (node.rs:119-139), so mihomo never sees the new key.
  • Helper: --runtime-contract-check forbids insecure and file paths, not certificate_public_key_sha256 (tooling/scripts/core-helper/main.swift:314-324). CI runs it and sing-box check on the emitted bytes, which now include a pinned hy2 outbound.
  • Records: product contract HY2 rows + evidence/equivalence; DECISIONS owner entry; ledger H21-O-F4 residual (it has no findings.d fragment, so per the README the ledger row is edited); changelog.d/2026-09-26-macos-hy2-spki-pin.md.

Why an SPKI pin is equivalent here

hy2 nodes serve operator-generated self-signed certificates. The private key is created on the node and never leaves it, and the DER pin never relied on a CA, name or validity period. So a key pin gives the same MITM protection. It also accepts a reissued certificate over the same key, which only the key holder can produce. sing-box sets InsecureSkipVerify only together with the SPKI VerifyPeerCertificate, and hysteria2 uses the default Go engine.

Tests (red first)

  • Red branch wip/macos-hy2-spki-pin-red @ 277bb163 (tests only):
    • macOS CI 36261397870: 445 tests, 3 failures, all in the two target tests. SingBoxConfigTests.swift:228 XCTAssertNil failed: "TONO_SINGBOX_HY2_DER_PIN_UNSUPPORTED"; :266 XCTAssertEqual failed (the pinned Fixture Beta · hy2 was also unavailable).
    • Services CI 36261397860: control-plane 931 passed, 1 failed (expected INVALID_CATALOG at catalog-yaml.test.ts:135, a 31-byte pin was admitted).
  • New/updated: XCTest testHY2WithPublishedSPKIPinGetsPinnedSingBoxOutbound, XCTest testProductRuntimePreservesHomeDirectAndRejectsHY2WithoutSPKIPin (renamed, pinned sibling added), vitest admits an optional block-style SPKI pin of exactly 32 bytes on hysteria2 blocks only, Ruby test_publish_keeps_the_hy2_spki_pin_beside_the_der_fingerprint (a guard; it already passes on main).
  • Local: control-plane vitest run 43 files / 932 passed; tsc --noEmit clean; the Ruby test passes. macOS was not built locally (hosted CI only).

Not in this PR

  • Publishing the pins. Until an operator adds certificate-public-key-sha256 to each hy2 block, macOS still has no usable hy2.
  • Customer visibility still depends on production HY2_CATALOG_EMAILS. When it is set, only listed accounts receive hy2 blocks (catalog.ts is unchanged).
  • The Rust sing-box emitter does not read the key.
  • No real-device hy2 connection yet.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HLfruZoE8SYQZsGmnciybx

raydocs and others added 2 commits September 26, 2026 12:06
Tests only. macOS: a hysteria2 block carrying certificate-public-key-sha256
must be available and emit tls.certificate_public_key_sha256; a block with
only the DER fingerprint stays unavailable. Control plane: the optional pin
must be block-style standard base64 of 32 bytes on hysteria2 blocks only.
Publisher: keeps the pin line verbatim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLfruZoE8SYQZsGmnciybx
sing-box 1.15.0-alpha.3 only pins tls.certificate_public_key_sha256 (base64
SHA-256 of the SubjectPublicKeyInfo); the catalog's hy2 fingerprint is the
leaf DER hash, so every hy2 node was unavailable on macOS.

A managed hysteria2 block may now carry certificate-public-key-sha256,
computed by the operator on the node beside the mandatory DER fingerprint.
macOS reads it, keeps only canonical base64 of 32 bytes, and emits
tls {enabled, server_name, certificate_public_key_sha256: [pin]}; never
insecure. A block without a valid pin stays unavailable with the existing
reason. Selection, the backup-channel offer and route choices already
filter on singBoxUnavailableReason, so pinned hy2 reaches them unchanged;
TCP failure still never auto-switches onto hy2.

The control plane admits the key on hysteria2 blocks only, once, as a
plain block-style line with a canonical 32-byte value; VLESS blocks must
not carry it. Windows is unchanged: RawProxy ignores unknown keys and the
mihomo runtime mapping emits only known fields.

Owner decision 2026-09-26 (DECISIONS.md); contract, ledger H21-O-F4 and
changelog.d updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLfruZoE8SYQZsGmnciybx
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

raydocs and others added 3 commits September 26, 2026 12:30
Tests only. A pin with a trailing comment and a pin nested under another
key both pass admission on 4e5e65f, but macOS keeps the comment in the
value and never reads a nested key, so the node silently stays unavailable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLfruZoE8SYQZsGmnciybx
Review ce2768d6 (opus:F1, codex:F1). catalogScalar stripped a trailing
comment and the key line was matched at any indentation, so a commented or
nested pin passed admission while macOS kept the comment in the value or
never read the nested key, silently leaving the node unavailable.

Admit the pin only as a direct field of the list item (the column of the
fields under `- name:`), plain or simply quoted, with nothing after it and
no deeper continuation line. Everything else is INVALID_CATALOG.

Suggestion opus:F2: manage-tono-hy2-node.sh apply reports
certificatePublicKeySha256 beside the DER fingerprint for every new
certificate, from the same openssl pipeline as the contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLfruZoE8SYQZsGmnciybx
…ISIONS entries)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLfruZoE8SYQZsGmnciybx
@github-actions

Copy link
Copy Markdown
Contributor
check measured budget
console initial JS gzip 196.0 KB 400.0 KB
console total JS gzip 291.0 KB 600.0 KB
console files over 400 lines 0 0
Worker src/index.ts lines 3787 4014

@raydocs

raydocs commented Sep 26, 2026

Copy link
Copy Markdown
Owner Author

jev-route review: dual_cross_family (opus + codex). ce2768d6 on 4e5e65f: PASSED, 2 minors (control plane accepted pin forms macOS cannot parse) + 1 suggestion (node script should print the SPKI pin) → fixed in f1f3fba (red a902b3a failed by assertion, Services CI 36262764273). 15213203 (--pr 668 at f1f3fba): PASSED, 0 findings. 71a8562 merges origin/main (DECISIONS.md conflict only; all entries kept). CI green on exact head 71a8562 (macOS CI 36263726887, Services CI 36263728344); no Windows paths. The published production catalog r62 already carries the pins in the accepted form. No unresolved threads.

@raydocs
raydocs merged commit 11d6295 into main Sep 26, 2026
24 checks passed
@raydocs
raydocs deleted the feat/macos-hy2-spki-pin-20260926 branch September 26, 2026 18:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant