soul.demarkus.io:6309/journal/2026-07-17.md/v3 draft reader meta

2026-07-17: join URLs + cert pinning (remote-issuance step 1)

Implemented step 1 of /plans/remote-token-issuance.md on branch join-string: the no-sidecar prefix (join-string grammar + TOFU cert pinning), after the Option C sidecar decision landed in the plan (v3) and roadmap (v58).

What shipped

  • client/joinurl (new package): Build/Parse of mark://host#token=...&fp=sha256:<hex> join URLs, cert fingerprint helpers (DER, PEM), VerifyPin TLS verifier. Fragment carries the credentials; it never appears in any protocol request.
  • client/fetch: Options.Pins (host to fingerprint map). A pinned host gets InsecureSkipVerify plus VerifyPin at dial time, per host, so a multi-host crawler client pins only what it should. Wire tests: right pin, wrong pin (handshake failure with re-join hint), bare-hostname fallback, unpinned hosts still CA-verify.
  • client/internal/tokens: entry gains pin field (tokens.toml, additive), SetPin/Pins/ResolvePin (flag > DEMARKUS_PIN > stored).
  • All three clients wire stored pins; demarkus-mcp gains -pin; new demarkus join '<join-url>' stores token + pin in one paste.
  • demarkus-token join subcommand: composes with generate via pipe, -cert embeds the fingerprint.
  • install.sh: summary now prints a ready-to-paste /soul-join <join-url> and demarkus join line.
  • Plugin: registry soul-join accepts a join URL (fragment supplies token + pin), souls catalog gains a 5th pin column (all readers were length-guarded), mcp-serve passes -pin instead of -insecure for pinned souls. commands/soul-join.md updated; plugin 0.12.16 to 0.13.0.
  • Docs: docs/site/tools/index.md join-URL section.

Decisions and gotchas

  • Never pin Let's Encrypt certs: leaves rotate on renewal (install.sh runs certbot twice daily) and would break every pinned client. install.sh embeds fp= only for custom certs; LE domains pass CA verification anyway. The dev self-signed cert has no stable file, so that path falls back to --insecure with a note.
  • Go url fragment gotcha: u.Fragment is pre-decoded; parsing it with ParseQuery corrupts + and % in tokens. Use u.EscapedFragment(). Conversely url.Values.Encode escapes : to %3A, uglifying the paste string; the fragment is hand-built (pin literal, token QueryEscaped).
  • Pin resolution mirrors token resolution exactly (explicit > env > stored), per the shared-behavior guideline; pins ride tokens.toml instead of a new file.
  • mcp-serve precedence: pin wins over insecure (pin verifies exactly one cert; insecure verifies nothing).

Verified live

Self-signed server on :6399: demarkus-token generate | join -cert produced the URL, demarkus join stored both credentials, publish + fetch ran with no --insecure, and a corrupted pin failed at the QUIC handshake with the actionable mismatch error. Plugin path: registry soul-join <join-url> wrote the 0600 token file, pinned=1, catalog row carries the pin.

Full make test + bash pre-commit.sh clean. Branch ready for PR; binary pin bumps ride the release automation after merge.

Follow-up: self-signed to CA migration

Question surfaced the scenario: server moves from a pinned self-signed cert to a CA cert. Pinned clients hard-fail (strict pin, by design); recovery is re-joining with a pinless join URL. The plugin path already handled it (catalog row upsert clears the pin), but demarkus join only wrote a pin when the URL carried one, so a stale pin survived re-join and kept failing. Fixed in the same branch: re-join re-states the trust material, a token-bearing URL without fp= clears the stored pin (with a stderr notice when one existed). Verified live against the e2e server. Lesson: when a credential store is updated by re-stating, absence of a field must overwrite, not preserve; the two join paths (upsert-row vs set-field) diverged on exactly that.

Cut: pinning removed, branch ships token-only

Fritz was uneasy about the direction; the complexity audit agreed with him. The join URL half is ~100 lines of formatting with zero trust semantics; the pinning half carried a third trust state, rotation coupling, a migration bug (caught pre-merge), mixed-version edges, and hand-rolled VerifyPeerCertificate. Every follow-up question in review prep was a pinning question. HPKP precedent (browsers added then removed pinning in 2018) and the narrow constituency (only domainless hosts; LE covers the rest) settled it.

Cut executed on the branch: joinurl is token-only (mark://host#token=..., unknown fragment keys still fail loudly so future keys cannot silently drop), fetch/tokens/CLI/TUI/MCP pin code removed, souls catalog back to 4 columns, demarkus-token join drops -cert, install.sh join line token-only with an --insecure note for self-signed, both plugin docs and site docs updated. Full test suite + pre-commit clean; token-only e2e re-verified live (CLI join, publish, plugin soul-join with fragment token).

Decision recorded in the plan (v4): pinning deferred; if verified joins for domainless hosts return, they ride the sidecar redeem handshake (fingerprint out of band in the redeem response), not user-visible trust state. Plan sequencing also revised: sidecar redeem-only demoted; authenticated create is the sidecar MVP.

Lesson: when a feature generates a stream of "what happens when..." questions before it ships, that is the complexity bill arriving early; the severable half that causes them is the half to cut.

trail
  1. soul.demarkus.io:6309 v3