Skip to content

M2-41: Host pairing and fingerprint

Status: ready | Depends on: M2-40 | Milestone: M2

Goal

After this ticket an owner registers a host with a one-time pairing code of the token format of ADR 0061 with kind hpc (bothy_hpc_<random>_<checksum>), shown exactly once and stored only as HMAC, valid once and only until terminal.pairing_code_ttl ends. The host service redeems it with pairHost (public, the code is the credential): it registers its static X25519 key (Noise) and its Ed25519 key (login), its platform, versions and profiles and the names of its session users, which the server stores in hosts.reported_session_users; the server computes the fingerprint of 24 "Fingerprint", raises key_generation and the host waits in confirming. The owner then confirms with step-up by typing the first terminal.fingerprint_confirm_chars characters of the fingerprint (confirmHost), declares the LLM login of the host (I6 checked in the same transaction) and whether the server runs there, and assigns each person their own session user, only from the reported names and never a taken one (T12); a wrong prefix gives 409 fingerprint_mismatch, and after terminal.fingerprint_max_attempts failures the host falls back to pairing with its keys dropped. A new code for an existing host (issueHostPairingCode, re-pairing) drops its keys at once, ends its sessions with host_rekeyed, audits the old fingerprint and, at the next redemption, the old and the new one, and tells every owner through host.updated with key_changed. The token generator of M1-44 knows the kind hpc, the API token authenticator never accepts it, and the secret scanner rule detects a generated code. Why: a host that anybody with an intercepted code could pair, or that the owner confirms blindly, would receive every later terminal session (24 3.2, R-ID-08 by analogy, T-TM-06); a re-pairing must leave no path for copied keys (T-TM-05) and must be visible to every owner, because pinning on the devices only warns about a change the server announces (T-TM-01, SEC-TERM-8). This is the server side of 24 "Pairing" and "Re-pairing" with TM-T19 and TM-T20 and of TM-T28 (b) and (e) at confirmation; M2-42 builds bothy-host pair against it, and M4-37 the screen A8.

Read first

  • docs/PLAN.md: section 3 "Way of working per ticket"; section 5 "Test strategy" (row "Security": TM-T01 to TM-T29, Go fuzzing of the parsers); section 8 "M2 Agent platform, extensions and terminal": "Goal", "Scope" (terminal part), "Out of scope", "Exit criteria" item 8, "Test strategy" (fake host in the server test), "Red team" (terminal paragraph, "pairing with an intercepted or guessed code"), "Risks"; block "terminal" (normative sources and order "Noise core, records, permissions, pairing, host service, ...") with the rows M2-39 and M2-40 (what exists), M2-41 (this ticket, the contract), M2-42 and M4-37 (they depend on this ticket), M2-43 (closes the connections of a re-paired host, writes report), M2-44 (opening, TM-T28 (e) at opening), M2-46 and M2-48; section 19: P-04, P-09, P-10, P-11, P-12, P-20, P-24.
  • Dependencies: the header names M2-40. Through it this ticket also builds on M2-39, M1-49, M1-48, M1-44, M1-35, M1-34, M1-33, M1-32, M1-31, M1-29, M1-28, M1-27, M1-26, M1-25, M1-24, M1-20, M1-18, M1-17, M1-16, M1-15, M1-13, M1-12 and their foundations (all implied, so not in the header). D-W-102 struck M1-44 from this ticket's plan column because it lies in the closure through M2-40; it stays in this list: docs/briefs/M1-44-identity-api-tokens.md is the brief whose token generator this ticket extends. Not in the closure, so do not rely on them: M1-19 (rate limiter), M1-21 (security headers), M1-47 (feature flags), M1-51 (agent records), M1-63 (catch-up listEvents), M2-38 (host module and its fingerprint.json), M2-27 (which may or may not have added the whi minting methods to the identity port first).
  • docs/spec/24-terminal.md (English, normative): 1 "Purpose and limits"; 2.1 "Overview"; 2.2 "Host service bothy-host" points 1, 2, 3 and 5 of "One session user per person and host, never shared" (assignment at confirmation, names reported at host.pair, 400 validation_failed with errors[].code session_user_not_on_host), HD1 and HD7 in the table; 2.3 "Server host" (the declaration at confirmation); 3.1 "States"; 3.2 "Pairing"; 3.3 "Re-pairing"; 3.4 "Second control connection" (the only way out of duplicate_connection is a re-pairing); 4.1 "Two key pairs per host"; 4.2 "Storage on the host"; 4.3 "Fingerprint"; 5.1 "Protection goal"; 5.5 "Key pinning on the device"; 7.2 "Declaration"; 7.3 "Invariant I6"; 8 "Permissions"; 10.1 "Audit"; 10.2 "Events"; 10.3 "Reasons for ending" (host_rekeyed); 11.1 "Definition" (host_profiles.v1, working directory lists); 13 "Errors"; 14 "Tests" (TM-T19, TM-T20, TM-T28); 16 (R-TM-01, R-TM-03, R-TM-05, R-TM-06, R-TM-13, R-TM-21, R-TM-23); 17 "Configuration values of this chapter".
  • docs/spec/03-data-model.md (core contract, read only): 1 "Principles" point 9 (only hashes of secrets); 4.3 the paragraph on permission_version (a transaction that changes a host's state, llm_credentials, credential_holder_id, declared_server_host, reported_session_users or rows of host_session_users raises it); 4.14 hosts (every column, every check, the unique partial indexes on auth_jkt and pairing_code_hash, the index on pairing_expires_at), host_session_users (unique rules, 409 conflict with reason: session_user_taken), terminal_sessions (close_reason host_rekeyed); 6.3 "Lock order"; 6.8 "I6 check"; R-DM-35, R-DM-39, R-DM-41.
  • docs/spec/05-actions.md (core contract, read only) and docs/spec/actions.yaml (M1-02): rule 1.7, rule 1.11, 2.1 (public, owner), 2.4 (columns Step-up, Audit, Permissions), 2.5, 4.14 rows host.read, host.manage, host.pair (Permission public (valid pairing code), Audit yes, Permissions no, rl.public_auth) and the paragraph after the table; 6 row host.updated (receive action host.read, key_changed at re-pairing, never keys); R-AZ-04.
  • docs/spec/04-sync-protocol.md (core contract, read only): 2.1 "Two kinds" (the terminal paragraph: host.updated is durable), 2.2 "Envelope".
  • docs/spec/08-identity.md: 6.3 "Fresh step-up for the terminal" (only to keep it apart: host.manage uses the normal step-up and consumes nothing), 12 "API tokens for agents, apps and tools" (form, storage, the kinds), 15 (ID-T23), 18 (auth.token_pepper_ref; auth.step_up_max_age is a key of 03).
  • docs/spec/07-permissions.md: 2.12 "Hosts and the terminal capability", 4.1 "Decisions and reasons" (unauthenticated, owner_only, subject_kind_not_allowed, i6_violation, terminal_no_session_user), 4.2 "Flow" (deferred entries), 4.3 "Visibility", 4.4, SR10, SR11, SR12, 11 (AZ-P1, AZ-P3, AZ-M54 to AZ-M61 owned by M2-40).
  • docs/spec/06-api.md: 2 "Basic rules" (no null, caching row), 3 "Authentication per principal kind" (the list of anonymous operations), 3.3 "Step-up", 5 "Errors" (points 3 and 7), 7 "Idempotency" (point 2, which answers are stored; point 3, x-idempotency-exempt: auth_flow), 8 "Concurrency" (If-Match, 412, 428), 16 "Endpoints" (group "Hosts").
  • docs/spec/02-architecture.md: 4.3 "Ports, events and sources per module" (row terminal: port Hosts with "administration, pairing, declaration"; row identity), 4.4 "Allowed dependencies" (terminal may use instance, identity, authz), 4.5, 6 "Code layout" (the product name exists in listed places only), 8.1 "Flow", 8.4 "Idempotency in the Unit of Work", 9 "Request path" (9.1, 9.2 "404 instead of 403", 9.3), 12.3 "Job catalogue" (row terminal.sweep).
  • docs/spec/10-backend-policy.md: 3 "Invariants" (I6).
  • docs/spec/19-security.md: SEC-IN-1, SEC-IN-4, SEC-HDR-2, SEC-LOG-1, SEC-LOG-2, SEC-AUD-5, SEC-SEC-4, SEC-TERM-5, SEC-TERM-8, SEC-TERM-12, SEC-T18.
  • docs/api/openapi.yaml: operations issueHostPairingCode, pairHost, confirmHost, and for context createHost, getHost, updateHost, setHostLlmCredentials, setHostState; schemas HostPairingCode, HostPairRequest, HostPairResult, HostConfirm, HostSessionUserAssign, HostSessionUser, HostReportedSessionUser, HostProfile, HostPlatform, HostState, LlmCredentials, Host, ReasonBody, HostUpdatedPayload, HostUpdatedEvent, Problem (x-problem-codes: fingerprint_mismatch, conflict, invalid_state_transition, i6_violation, unauthenticated, step_up_required, validation_failed, version_conflict, precondition_required), parameters HostIdPath, IdempotencyKey, IfMatchRequired. docs/api/asyncapi.yaml: message event.host.updated (x-receive-action: host.read); channel hostControl (x-close-codes 4110 host_unpaired, for the M2-43 part).
  • ADRs: docs/adr/0061-token-format-with-a-fixed-prefix-and-checksum.md (form with kind hpc, check order, storage, scanner, "Confirmation"), 0068-terminal-through-a-host-service-with-noise-stream-and-invariant-i6.md (decision points 1, 2, 5, 7 and the T12 addendum), 0067-jose-profile-with-the-standard-library.md (JWK parsing only through kernel/jose), 0017, 0020, 0044 (product name in the prefix), 0053 (no null), 0070 (pinning limits in the console).
  • docs/DECISIONS.md: the rulings T1 to T13 (D-T1 to D-T11 in section 7.1, T12 as D-W-57 and T13 as D-W-62 in section 9); D-T3, D-T13, D-T25, D-T26, D-T31, D-T32, D-T36, D-T37 (sections 7.2 and 7.3); D-W-63 (names reported at host.pair, accepted at confirmHost), D-W-65, D-W-100, D-W-101, D-W-102; section 10 for the format of new rows. No other D-W ruling names M2-41.
  • docs/security/threat-model.md: 5.16 "Terminal" (rows "Pairing"), 6.7 "Terminal" (T-TM-01 to T-TM-14, in particular T-TM-01, T-TM-05, T-TM-06, T-TM-08). docs/RISKS.md: RK-23. docs/OPEN-QUESTIONS.md: OQ-03 and OQ-04 (default answers; they concern M2-45 and M2-46, not this ticket), OQ-24 (terminal only over a private network; default no).
  • docs/operations/configuration.md: terminal.pairing_code_ttl, terminal.fingerprint_confirm_chars, terminal.fingerprint_max_attempts, auth.token_pepper_ref, auth.step_up_max_age, host.os_user_max_chars, audit.reason_max_chars, audit.deny_aggregate_window, ratelimit.rl.public_auth.per_minute, ratelimit.rl.public_auth.burst, http.max_json_body_bytes. docs/operations/tailscale.md 3 "Tailnet policy (required)" for the network point. docs/observability/signals.md 4.3a "Terminal" (this ticket adds no metric).
  • .gitleaks.toml rule bothy-api-token (already lists hpc and the keyword bothy_hpc_); tools/ci/tests/test_gitleaks_rules.py; CONTRIBUTING.md (marker EXAMPLE for committed token-shaped values).
  • Briefs: docs/briefs/M1-44-identity-api-tokens.md (domain/apitoken, testdata/checksum_vectors.json, the redacting secret type, the HMAC with the pepper, the authenticator rejecting whi), M1-13-unit-of-work.md, M1-18-http-chain.md (kernel/errs, httpserver.WriteProblem, precondition stage), M1-20-idempotency-keys.md (the stage algorithm, MarkOneTimeSecret), M1-24-audit-chain.md (auditing.Recorder with Record and RecordDenial, Attrs value kinds, the code pattern of SEC-AUD-5), M1-28-authz-core.md (requirement table, text mapping, deferred entries, visibility), M1-29-authz-invariants.md (txn.RightsChange), M1-33-identity-devices-dpop.md (kernel/jose ParseJWK, Thumbprint), M1-35-identity-step-up.md (step-up test helper). Briefs of M2-39 and M2-40, if they exist in docs/briefs/ when you start.
  • Code on the base branch: internal/terminal (M2-39: port Hosts, domain with the state machine of 24 3.1, app with createHost, updateHost and its session user assignment function, the function that ends all sessions of a host, the host.updated writer and the Host mapper, adapters/postgres, adapters/httpapi; M2-40: the declaration of the LLM login behind setHostLlmCredentials with the I6 check), internal/identity (domain/apitoken, port Tokens, authenticator, HMAC hasher), internal/authz (requirements, golden list of deferred entries, visibility, the host rights-change path of M2-39 and M2-40), internal/kernel/{txn,auditing,errs,jose,textcheck,clock,actor,idempotency}, internal/platform/httpserver, internal/wire (configkeys_*.go, configchecks.go, configchapters.go), api/operations.yaml.

Fixed requirements that apply

Owner's fixed build rules (apply verbatim):

  1. No MVP. New from scratch and directly in the final state; stages are only the order.
  2. Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
  3. Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
  4. Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
  5. Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.

Rules for every brief:

  1. Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
  2. No en dash and no em dash in any file you write; the text-rules pre-commit hook rejects them. Use a hyphen or rephrase.
  3. White label: examples and test data use example.com and the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory.
  4. Limits, windows and thresholds only through named configuration keys listed in docs/operations/configuration.md; code comments and docs name the key, never the number.
  5. Specification first: a change to behaviour, API, event, right or configuration updates docs/spec/ or docs/api/ in the same change. The core contract (docs/spec/00-glossary.md, 03-data-model.md, 04-sync-protocol.md, 05-actions.md) is not edited by this ticket.

Product requirements implemented here:

  1. The pairing code has the form bothy_hpc_<random>_<checksum>: 256 bits from crypto/rand in Base62 as exactly 43 characters, CRC-32 (IEEE) over the random part as exactly 6 Base62 characters, generated by domain/apitoken of M1-44 extended by the kind hpc (ADR 0061 point 1, 24 3.2, D-T32).
  2. Only the HMAC-SHA-256 of the code with the pepper of auth.token_pepper_ref is stored (hosts.pairing_code_hash); the code is shown exactly once in HostPairingCode and appears in no log, audit entry, event, job, idempotency row or problem body (03 point 9, 24 3.2, SEC-LOG-1).
  3. A code works once and only before terminal.pairing_code_ttl ends; a used, replaced, expired or unknown code and a code with a wrong checksum give 401 unauthenticated with the same body; a wrong form or checksum is rejected without any database access (TM-T20, ADR 0061 point 2).
  4. pairHost (host.pair: public with a valid code, Audit yes, rl.public_auth) stores both public keys, platform, versions, profiles and the reported session users in hosts.reported_session_users, computes key_fingerprint (24 4.3), raises key_generation and moves the host pairing -> confirming (24 3.1 and 3.2, D-W-63).
  5. confirmHost (host.manage: owner, step-up, Audit, Permissions yes, Idempotency-Key, If-Match) compares the typed prefix of the fingerprint; a mismatch gives 409 fingerprint_mismatch and the host stays confirming; after terminal.fingerprint_max_attempts failures it falls back to pairing and its keys are dropped (TM-T19, 24 3.2, D-T31).
  6. With the right prefix confirmHost declares llm_credentials and the credential holder with I6 in the same transaction (409 i6_violation, 03 6.8, 24 7.3), records declared_server_host (24 2.3), assigns session users only from the reported names (400 validation_failed with errors[].code session_user_not_on_host) and never a name that belongs or belonged to another person or a second valid assignment of one person (409 conflict with reason: session_user_taken), stores nothing on any of these failures (TM-T28 (b) and (e), T12, D-W-63), and moves the host confirming -> active.
  7. issueHostPairingCode (re-pairing) from pairing, confirming, active or disabled issues a new code, drops the stored keys at once, ends the sessions of the host with host_rekeyed in the same transaction, audits the old fingerprint and writes host.updated with key_changed = true, which every owner receives through host.read; the next pairHost raises key_generation and audits old and new fingerprint (24 3.3, SEC-TERM-8).
  8. Every transaction of this ticket that changes a host's state, its declaration, declared_server_host, reported_session_users or rows of host_session_users raises the rights version first (03 4.3 and 6.3, 05 rule 1.7).
  9. The kind hpc is never accepted as an API token and never issued by createToken; the gitleaks rule bothy-api-token detects a generated code (SEC-SEC-4, D-T32, PLAN row).
  10. No JSON null; no private key member is ever accepted or stored; events, logs, audit details, problem bodies and test failure messages never contain a pairing code, its HMAC or key material other than what the Host representation shows (ADR 0053, 05 6 row host.updated, SEC-AUD-5).

Terminal security requirements (D-W-101):

  1. Rights per machine: the additional right terminal on the target host, SR10, SR11 with I6 and SR12 (07 "Hosts and the terminal capability"). M2-39 owns host.read and host.manage (AZ-M60), M2-40 owns terminal.*, SR10, SR11, SR12 and I6 at grant.create (AZ-M54 to AZ-M59, AZ-M61, TM-T08, TM-T09, TM-T28 (a)). This ticket enforces the parts of the confirmation that feed those rules: only an owner with step-up issues codes and confirms (TestConfirmHost_RightsAndPreconditions, TestIssuePairingCode_Rights); the declaration at confirmation runs the I6 check transactionally (TestConfirmHost_I6Violation); declared_server_host, the input of SR10 for the server host, is set only there (TestConfirmHost_HappyPath); the session user assignments, the data of SR12, accept only reported names and never a taken name (TM-T28 (b) and (e): TestTMT28b_ConfirmSessionUserTaken, TestTMT28e_ConfirmNameNotReported). host.pair becomes a real requirement (Public, the code is the proof) instead of a deferred entry (TestHostPairRequirement).
  2. Fresh step-up: device key in the apps, only passkey in the console (08 6.3, ID-T28). Not touched here: M2-40 builds step-up fresh and its consumption, M2-44 TM-T07 without the console part, M6-18 the console with passkey only, M4-36 and M5-12 the step-up key in the apps. This ticket's actions use the normal step-up of host.manage (auth.step_up_max_age), and a confirmation never consumes the fresh step-up of the terminal (TestConfirmHost_HappyPath asserts auth_sessions.step_up_consumed_at unchanged; TestConfirmHost_RightsAndPreconditions asserts 401 step_up_required without step-up).
  3. Network: there is no public inbound path to the terminal; the host service opens only outbound connections (R-TM-01); an installation with the profile tailscale reaches the server only over the tailnet with a policy only for port 443 and without Funnel (D-OP-03, docs/operations/tailscale.md); the product does not require Tailscale; whether the terminal must be reachable only over a private network is owner question OQ-24 (default no). This ticket adds one public operation, pairHost, called outbound by the host service on the normal API listener: it is limited per client IP by rl.public_auth (M1-19's limiter; the route keeps the class, TestPairingRoutes_AZP1), rejects garbage codes without touching the database (TestTMT20_WrongChecksumNoDatabase) and returns only public data (TestPairHost_HappyPath). It adds no listener and no inbound path to a host. M2-43 builds the outbound control connection and M2-44 the data connection and relay; on an answer "yes" to OQ-24 those change, not this one.
  4. Audit without content: this ticket writes host.manage and host.pair entries with IDs, codes, integers and fingerprints only, never the pairing code, its HMAC, a public or private key, a path or a working directory name (TestPairingNeverLeaksSecrets); M2-46 owns the audit of sessions and the metrics without content (TM-T06), M2-44 the relay that logs only length, time and session ID.
  5. Idle timeout and maximum duration (24 "Limits"): not touched; M2-45 measures idle on the host, M2-46 adds the server side with terminal.idle_timeout plus terminal.idle_grace and terminal.max_session_duration (TM-T11). The only session end this ticket causes is host_rekeyed at re-pairing, through the session-ending function of M2-39 (TestRekey_FromActive).
  6. Service user and session users without administrator rights (T12, HD rules): M2-42 builds install, doctor with HD1 to HD11 and the key storage (TM-T13, TM-T22, TM-T24), M2-45 runs every PTY in the helper of the session user. This ticket enforces the server half of T12 at pairing and confirmation: pairHost accepts only a non-empty list of distinct session user names that match the pattern and host.os_user_max_chars (mirrors HD1, TestPairHost_ReportRules), and confirmHost gives each person their own, never reused name (TM-T28 (b) and (e)).
  7. Threats of threat model 6.7 that concern this ticket, each with its test: T-TM-06 (pairing of a foreign host with an intercepted or guessed code) by a one-time, short-lived code with checksum and secret scanner rule, the typed fingerprint and limited failures: TM-T19 (TestTMT19_FingerprintMaxAttempts), TM-T20 (TestTMT20_PairingCodeRejections, TestTMT20_WrongChecksumNoDatabase), TestHostPairingCode_MatchesGitleaksRule and test_pairing_code_with_valid_checksum_is_detected; T-TM-01 (the server swaps a host key) by the audit of every re-pairing with both fingerprints and host.updated with key_changed to every owner (TestRekey_FromActive, TestRekey_AuditNamesBothFingerprints), while the hard warning on the device is TM-T18 (M4-35, M4-36); T-TM-05 (stolen host keys) by dropping both keys at once at re-pairing and refusing a re-sent key pair (TestRekey_FromActive, TestPairHost_RejectsReusedKeys), while the second control connection is TM-T23 (M2-43); T-TM-08 (someone else's LLM login) by the own session user per person and I6 at confirmation (TestTMT28b_ConfirmSessionUserTaken, TestConfirmHost_I6Violation). Not touched here: T-TM-02 (M2-38, M2-45), T-TM-03 and T-TM-09 (M2-40, M2-44), T-TM-04 (M2-42), T-TM-07 (M6-18), T-TM-10 (M4-35), T-TM-11 (M2-45, M2-46), T-TM-12 and T-TM-13 (M2-42, M2-45), T-TM-14 (M2-42, M2-43, M2-45).

Scope

All paths under services/server/ unless noted. Names of M2-39 and M2-40 below are the expected ones; where the merged code names them differently, follow the code (Boundaries). This ticket creates no migration (every column exists since M2-39) and no new top-level make target.

Create:

  • internal/terminal/domain/fingerprint.go: const FingerprintLabel = "bothy-host-fp/1"; ComputeFingerprint(noisePublicKey, authPublicKey []byte) (string, error) (both exactly 32 bytes, result in display form); CompactFingerprint(display string) string; the unexported Crockford encoder (Design note 5). internal/terminal/domain/testdata/fingerprint_vectors.json with the known vector of Design note 5.
  • internal/terminal/domain/pairing.go: pure functions ParseHostKeys(noiseKey string, jwk map[string]any) (HostKeys, []FieldError), ValidatePairReport(r PairReport, lim ReportLimits) []FieldError, NormalizeFingerprintPrefix(s string, n int) (string, error), PrefixMatches(fingerprint, prefix string) bool (constant time), NextAttempt(failuresBefore, maxAttempts int, match bool) AttemptOutcome and RepairEffects(state HostState) (RepairPlan, error) (Design notes 3, 6 and 8). FieldError is the type the merged kernel/errs uses for errors[].
  • internal/terminal/app/pairing.go: IssuePairingCode (the shared issuer of Design note 2), PairHost, ConfirmHost; outgoing port PairingCodes { New(ctx context.Context) (identity.HostPairingCode, error); Hash(ctx context.Context, raw string) ([]byte, error) } bound to the identity port in internal/wire.
  • internal/terminal/adapters/postgres/queries/pairing.sql: find a host by pairing_code_hash without lock; lock one host FOR UPDATE; set a new code; drop the keys; store a pairing; increment fingerprint_failures without changing version; confirm. Through the M2-39 transition function where it exists.
  • Fuzz targets FuzzParseHostKeys and FuzzNormalizeFingerprintPrefix next to pairing.go.

Change:

  • internal/identity/domain/apitoken (M1-44): the kind hpc next to agt, app, pat and whi; the authenticator keeps rejecting whi and now also hpc without a database call; createToken never derives hpc. testdata/checksum_vectors.json: one vector of kind hpc with the random part EXAMPLEhostpairingcode000000000000000000000 and the checksum 4SigQv (computed independently for this brief; the random part carries the EXAMPLE marker).
  • internal/identity port Tokens (M1-44): NewHostPairingCode(ctx context.Context) (HostPairingCode, error) with HostPairingCode{Secret <the redacting secret type of M1-44>; Hash []byte}, and HashHostPairingCode(ctx context.Context, raw string) ([]byte, error) returning ErrTokenMalformed without any database call for every value that is not a well-formed token of kind hpc (form, checksum, kind). If M2-27 already added ErrTokenMalformed, reuse it; otherwise create it with a doc comment. Both use domain/apitoken and the HMAC hasher of auth.token_pepper_ref (Design note 1).
  • internal/terminal port Hosts (M2-39): IssuePairingCode(ctx context.Context, a actor.Principal, cmd IssuePairingCodeCmd) (PairingCodeGrant, error), Pair(ctx context.Context, cmd PairHostCmd) (PairResult, error), Confirm(ctx context.Context, a actor.Principal, cmd ConfirmHostCmd) (Host, error) with IssuePairingCodeCmd{HostID ids.ID; Reason string}, PairingCodeGrant{HostID ids.ID; Code <redacting type>; ExpiresAt time.Time}, PairHostCmd{Code string; NoisePublicKey string; AuthPublicJWK map[string]any; Platform, OSVersion, HostVersion string; Profiles []Profile; SessionUsers []ReportedSessionUser}, PairResult{HostID ids.ID; KeyFingerprint string; InstanceID ids.ID}, ConfirmHostCmd{HostID ids.ID; IfMatch int64; FingerprintPrefix, LLMCredentials string; CredentialHolderID *ids.ID; DeclaredServerHost bool; SessionUsers []SessionUserAssign; Reason string}. actor.Principal stands for the request principal type of kernel/actor.
  • internal/terminal/app (M2-39): createHost issues its code through IssuePairingCode's shared function (Design note 2); the Host mapper omits key_fingerprint and noise_public_key while the host has no keys (Design note 9).
  • internal/terminal/adapters/httpapi: strict-server methods of issueHostPairingCode, pairHost, confirmHost; Cache-Control: no-store set by the handler on every answer of the three operations (SEC-HDR-2, whether or not M1-21 is merged); ETag on the 200 of confirmHost.
  • internal/kernel/errs and internal/platform/httpserver (M1-18): an optional problem member attempts_remaining carried by the error of fingerprint_mismatch and written by WriteProblem (Design note 7); the test of the problem shape per code.
  • internal/kernel/idempotency and the idempotency stage in internal/platform/httpserver (M1-20): KeepEffectOnFinal(ctx) next to MarkOneTimeSecret; for a marked final error answer the stage stores it with Store.Complete in the same unit of work and commits instead of rolling back (Design note 7e); its doc comments and the stage test.
  • internal/authz: the requirement entry of host.pair is Public instead of Deferred (row public (valid pairing code) of the text mapping); the golden list of deferred entries without host.pair; the visibility row host additionally allows a principal for an action whose requirement is Public (Design note 10).
  • internal/wire: bindings of PairingCodes; configuration keys this ticket is the first consumer of, among terminal.pairing_code_ttl, terminal.fingerprint_confirm_chars, terminal.fingerprint_max_attempts, auth.token_pepper_ref, host.os_user_max_chars, audit.reason_max_chars; the new keys terminal.report_label_max_chars (KindInt, minimum 1, default 100) and terminal.report_max_entries (KindInt, minimum 1, default 64) with the chapter constant of 24-terminal.md in configchapters.go; the start checks of Design note 12 in configchecks.go.
  • api/operations.yaml: move issueHostPairingCode, pairHost and confirmHost (and createHost, if still later, Design note 2) from later to enabled; rerun the route generator (M1-17).
  • tools/ci/tests/test_gitleaks_rules.py (repository root): test_pairing_code_with_valid_checksum_is_detected; the existing cross-check of the Go checksum vectors covers the new hpc vector. .gitleaks.toml already lists hpc; change it only if the test proves it wrong.
  • docs/spec/24-terminal.md (English, like the chapter): 3.2 (HMAC input, 401 unauthenticated for every redemption failure with the same body, form and checksum without database access, the key and report rules of Design note 4 with their errors[].code values invalid_key, unknown_member, empty, duplicate, too_long, limit_reached and invalid_format, the prefix rules of Design note 6, attempts_remaining, the fallback of Design note 8); 3.3 (key_generation rises at host.pair, key_changed exactly when stored keys are dropped, the retained fingerprint and 409 conflict with reason: host_key_reused); 10.1 (details of host.manage pairing_code and confirm and of host.pair, Design note 11); 13 (row conflict: reasons host_key_in_use and host_key_reused; row fingerprint_mismatch: attempts_remaining); 17 (rows terminal.report_label_max_chars and terminal.report_max_entries).
  • docs/spec/08-identity.md 12 "Form": the kind hpc (pairing code of a host, issued only through host.manage, never accepted as an API token). docs/spec/06-api.md: 3 the anonymous list names the pairing of a host (pairHost); 5 point 3 names attempts_remaining at fingerprint_mismatch. docs/spec/02-architecture.md 4.4 row terminal: the reason column names the pairing code (format and HMAC through identity); 8.4: the exception of Design note 7e (a final error answer marked with KeepEffectOnFinal keeps its effect and is stored in the same transaction; only 409 fingerprint_mismatch of confirmHost is marked).
  • docs/api/openapi.yaml: Problem.attempts_remaining (integer, minimum 0, "present with fingerprint_mismatch; 0 means the host fell back to pairing and needs a new pairing code"); the x-problem-codes description of fingerprint_mismatch; the descriptions of issueHostPairingCode (allowed states, 409 invalid_state_transition for retired, key_changed when stored keys are dropped), pairHost (the 401 cases, the 409 conflict reasons host_key_in_use and host_key_reused, the key and report rules with their errors[].code values) and confirmHost (prefix rules with errors[].code invalid_format, attempts_remaining, the failure counter does not change version); Host.key_fingerprint and Host.noise_public_key "absent while the host has no keys". Then make api-lint, make api-checks, make api-tables and the server code generation.
  • docs/adr/0061-token-format-with-a-fixed-prefix-and-checksum.md "Confirmation": the tests of the kind hpc; make adr-index.
  • docs/security/threat-model.md 6.7 row T-TM-06, column "Tests": add the test names of requirement 27.
  • docs/operations/configuration.md: regenerate with make config-reference.
  • READMEs of internal/terminal (pairing, confirmation, re-pairing, the shared issuer), internal/identity (kind hpc, the two port methods) and internal/authz (host.pair); services/server/CHANGELOG.md under "Unreleased"; docs/DECISIONS.md section 10 rows D-M2-41-<n> for every ruling of Design note 14.

Out of scope

  • Tables hosts, host_session_users, terminal_sessions, the state machine, listHosts, getHost, updateHost, setHostState, the requirement entries of host.read and host.manage, the host.updated writer, the session-ending function and the assignment function with TM-T28 (b) and (e) through updateHost (M2-39); createHost itself (M2-39), except routing its code through the shared issuer (Design note 2).
  • terminal.* rights, SR10, SR11, SR12, I6 at grant.create, setHostLlmCredentials, step-up fresh, the hint terminal_not_effective and AZ-M54 to AZ-M61 (M2-40).
  • bothy-host pair (key generation and storage on the host, the local fingerprint display, the check against the server's fingerprint), doctor and install (M2-42).
  • The control connection, report (which writes hosts.reported_session_users after pairing), closing the connections of a re-paired host with 4110, presence and the version check 4114 (M2-43). Until then a host has no connection to close.
  • Opening sessions, the session token, the open part of TM-T28 (e) ("after the next report 403 terminal_denied with reason: no_session_user, no 4104") and TM-T07 (M2-44); revocation, limits and metrics (M2-46).
  • The job terminal.sweep (02 12.3), also its clause on expired pairing codes: no ticket row names the job; an expired code is rejected at redemption here (TM-T20), and the 03 check keeps its hash while the host stays pairing (report it as a follow-up).
  • Pinning and the hard warning on devices (TM-T18: M4-35, M4-36), screen A8 (M4-37), the console (M6-18). Dev environments as hosts (V2, D-W-65).
  • Changes to the core contract.

Boundaries

  • Work in a dedicated git worktree on the branch feat/M2-41-host-pairing; one writer per worktree.
  • Conventional Commits with the module as scope (for example feat(terminal): ..., feat(identity): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md).
  • Never push to main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request of AGENTS.md.
  • Do not modify files outside the scope above, except the CHANGELOG, README and docs/DECISIONS.md entries named in this brief.
  • Follow the host rules supplied by the launcher (ports, machine rules).
  • Tests bind only 127.0.0.1 and port 0; PostgreSQL runs only as a throwaway container on a random port through pgtest (P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip.
  • Stop only processes you started, by PID. Temporary directories only via mktemp -d, removed at the end.
  • Never read or print secrets. No network calls except package downloads for pinned tools and Go modules.
  • Never lower a threshold, disable a gate or delete a test.
  • If the core contract lacks something this ticket needs, stop with Verdict: BLOCKED <reason> instead of building a deviation (P-11).
  • This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with Verdict: BLOCKED <reason>.
  • The same holds for M2-39 and M2-40: their merged names win over the expected names here; list each difference. If the base branch has no hosts table, no host.manage requirement entry or no I6 check in a declaration function, a dependency is missing: stop with Verdict: BLOCKED <reason>.
  • Pairing codes in tests are built at runtime; a committed code-shaped value carries EXAMPLE in its random part (CONTRIBUTING.md). Never write a complete bothy_hpc_ value with a valid checksum into a committed file, a log line or a test failure message; print a hash prefix if needed.
  • Time only through kernel/clock; tests move a fake clock, they never sleep for terminal.pairing_code_ttl.
  • No real host service: the tests play the host service with plain Go HTTP calls and keys generated in the test.

Design notes

  1. Code and hash (identity). NewHostPairingCode generates a token of kind hpc with domain/apitoken (the constant bothy stays exactly once, M1-44) and returns it in the redacting secret type of M1-44 together with Hash = HMAC-SHA-256(pepper of auth.token_pepper_ref, full code string), the same derivation as api_tokens.token_hash. HashHostPairingCode parses (form, kind hpc, checksum) and returns ErrTokenMalformed before computing any HMAC for a wrong value. Ruling for docs/DECISIONS.md: one derivation for every token of ADR 0061; the prefix bothy_hpc_ already separates the kinds, so no extra label is hashed. The authenticator of M1-44 treats hpc like whi: 401 unauthenticated without a database call.

  2. Shared issuer. IssuePairingCode's inner function issueCode(ctx, hostRow) runs inside the caller's unit of work: PairingCodes.New, pairing_code_hash = Hash, pairing_expires_at = now + terminal.pairing_code_ttl, fingerprint_failures = 0; it returns the code. createHost (M2-39) and issueHostPairingCode both use it. Check the merged code first: a. M2-39 already mints hpc codes through domain/apitoken and the identity port: keep its names, add only what is missing and route issueHostPairingCode through the same function. b. M2-39 mints codes another way (its own generator or another HMAC input): replace that path with the identity port, so the format exists once and the hash derivation is one; createHost keeps its behaviour and its tests. c. createHost is still later on the base branch: enable it here with the shared issuer, with TestCreateHost_IssuesCode, because it is the only way to create a host in pairing. Name the case in the report. Ruling for docs/DECISIONS.md: one issuer for both operations.

  3. pairHost (POST /api/v1/hosts/pair, host.pair, security: [], rl.public_auth, x-idempotency-exempt: auth_flow), in this order: a. The schema validator of M1-18 rejects a missing member or a pairing_code outside its pattern with 400 validation_failed. b. PairingCodes.Hash: ErrTokenMalformed (wrong checksum, kind not hpc) gives 401 unauthenticated; no unit of work is opened and no query runs; the denial goes to auditing.Recorder.RecordDenial (action host.pair, reason unauthenticated, no resource), which aggregates it per IP pseudonym and audit.deny_aggregate_window (R-AZ-04) and writes outside the request. c. ParseHostKeys and ValidatePairReport (pure, no database): every finding is 400 validation_failed with a pointer into the body and leaves the code valid (Design note 4). d. Read the host by pairing_code_hash without lock (unique partial index). No row, or pairing_expires_at <= now: 401 unauthenticated with the same body as in b and a denial through RecordDenial (resource host with its ID when a row matched). e. One unit of work with txn.RightsChange{Action: host.pair, Resource: the host, Targets: [the host]} as M2-39 and M2-40 use it for a host (03 4.3 lists reported_session_users, so the rights version rises first, although 05 gives host.pair Permissions no; ruling for docs/DECISIONS.md). Lock the host FOR UPDATE and check again that pairing_code_hash equals the hash, state = 'pairing' and pairing_expires_at > now; otherwise roll back and answer as in d (a parallel redemption won). f. authorize(principal, host.pair, Resource{Kind: host, ID}) gives allow through Public (Design note 10). g. ComputeFingerprint; equal to the retained key_fingerprint of this host: 409 conflict with reason: host_key_reused (Design note 9), roll back, the code stays valid. h. Write noise_public_key, auth_public_jwk (exactly {"kty":"OKP","crv":"Ed25519","x":...} as built by the server), auth_jkt, key_fingerprint, key_generation + 1, fingerprint_failures = 0, paired_at = now, platform, reported_os_version, reported_host_version, reported_profiles, reported_session_users (exactly as reported, order kept; through the M2-40 helper for a change of the report if it exists, so M2-43's report writes the column the same way), pairing_code_hash = NULL, pairing_expires_at = NULL (ruling: the index on pairing_expires_at serves only pairing), state confirming through the M2-39 transition, version + 1. A unique violation on auth_jkt (the key belongs to another host, also a retired one) rolls back with 409 conflict and reason: host_key_in_use. i. host.updated through the M2-39 writer without key_changed; audit allow host.pair (Design note 11). Answer 201 HostPairResult{host_id, key_fingerprint, instance_id} with instance_id from the instance port (M1-15). A retry after a lost answer gets 401 unauthenticated, because the code is used (D-T37); the operator asks the owner for a new code (M2-42 prints that hint).

  4. Key and report rules (ParseHostKeys, ValidatePairReport; pointers relative to the body; ruling for docs/DECISIONS.md: these rules and their errors[].code values): a. noise_public_key: strict unpadded base64url (base64.RawURLEncoding.Strict()), exactly 32 bytes, else invalid_key; a low-order point is invalid_key too: crypto/ecdh X25519().NewPublicKey, then an ECDH with a fresh private key; an error there means the key can never complete a Noise handshake (M2-38 rejects a zero result). b. auth_public_jwk: exactly the members kty (OKP), crv (Ed25519) and x; any other member, the private member d above all, is unknown_member at /auth_public_jwk/<name>; x must decode strictly to 32 bytes, else invalid_key; parse it with kernel/jose.ParseJWK and take auth_jkt from JWK.Thumbprint() (RFC 7638); auth_public_key_raw of the fingerprint is the decoded x. c. session_users: at least one entry (empty; a host service without session users fails HD1 and can never give a terminal); each os_user matches ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ and has at most host.os_user_max_chars characters (too_long); names are distinct without regard to case (duplicate); each workdirs entry is a non-empty display text (kernel/textcheck.DisplayText) of at most terminal.report_label_max_chars characters. d. profiles: names match the schema pattern, are distinct (duplicate) and at most terminal.report_label_max_chars characters long; display_name is a display text of at most that length; an empty list is allowed (HD7 may drop every profile). e. profiles, session_users and the workdirs of one session user each have at most terminal.report_max_entries entries (limit_reached). f. host_version is a SemVer 2.0.0 version without a leading v (invalid_format); os_version, when present, a display text of at most terminal.report_label_max_chars characters.

  5. Fingerprint (24 4.3, the same function as M2-38 note 7): SHA-256("bothy-host-fp/1" || noise_public_key || auth_public_key_raw) (32 raw bytes each), Crockford base32 with the alphabet 0123456789ABCDEFGHJKMNPQRSTVWXYZ, 5-bit groups from the most significant bit, the last group padded with zero bits on the right, upper case, no padding character, no check symbol, 52 characters shown as 13 groups of four joined by -. Known vector, computed independently for this brief: noise key bytes 0x00 to 0x1f and auth key bytes 0x20 to 0x3f give F0CC-6QS6-RF5D-3BSH-CAZQ-PXMR-J8RA-AHK5-1HF0-9S02-1GW6-BW3J-HK90. If services/host/testdata/noise/fingerprint.json exists on the base branch (M2-38 merged), add TestFingerprint_HostVectors, which reads that file and checks every entry; otherwise do not add it and say so in the report (M2-42 compares both sides end to end). Ruling for docs/DECISIONS.md if M2-38 has not written 24 4.3 yet: the encoding details above go into 24 4.3.

  6. Prefix rules (HostConfirm.fingerprint_prefix, "without separators, case-insensitive"): upper-case it; it must have exactly terminal.fingerprint_confirm_chars characters, all from the Crockford alphabet, else 400 validation_failed with pointer /fingerprint_prefix and errors[].code invalid_format, and the attempt does not count. A well-formed prefix is compared with the first characters of CompactFingerprint(key_fingerprint) by crypto/subtle.ConstantTimeCompare. Ruling for docs/DECISIONS.md: only a well-formed prefix counts as an attempt; letters outside the alphabet (I, L, O, U) are not mapped, the client may normalise before sending.

  7. confirmHost (POST /api/v1/hosts/{host_id}/confirm, host.manage, step-up, rl.admin, Idempotency-Key, If-Match), in this order: a. 428 precondition_required without If-Match comes from the precondition stage of M1-18; the schema validator rejects missing members. b. Load the host; unknown gives 404 not_found. authorize(actor, host.manage, host): invisible gives the same 404, a visible host without owner rights 403 forbidden with reason: owner_only, no fresh enough step-up 401 step_up_required (M1-28, M1-35). c. Body rules without the database: reason (kernel/textcheck, at most audit.reason_max_chars, else 400 validation_failed with errors[].code too_long), the prefix of Design note 6, the declaration's own validation of M2-40 (credential_holder_id exactly with personal, as the 03 check demands). d. One unit of work with txn.RightsChange{Action: host.manage, Resource: the host, Targets: [the host]}; lock the host FOR UPDATE; version different from If-Match: 412 version_conflict with current; state <> 'confirming': 409 invalid_state_transition. e. Prefix mismatch: NextAttempt raises fingerprint_failures without changing version (the counter is not part of Host, so the owner's next attempt keeps its If-Match); when it reaches terminal.fingerprint_max_attempts, the fallback of Design note 8 runs in the same unit. Audit allow host.manage (Design note 11). confirmHost declares Idempotency-Key. With a key, the M1-20 stage owns the unit of work, the use case's Do joins it and never commits (M1-13), and the stage rolls back every final error (02 8.4). Without a key, the use case's unit is the outermost: return the outcome from the Do function so it commits, then return the error. With a key, the handler calls the new idempotency.KeepEffectOnFinal(ctx) (in internal/kernel/idempotency next to MarkOneTimeSecret; the stage in internal/platform/httpserver, M1-20) before it writes 409 fingerprint_mismatch. For a marked final answer the stage stores it with Store.Complete in the same unit and commits, so the counter, the fallback of Design note 8, the audit entry, host.updated and the stored answer commit together. Only this answer is ever marked. Add this exception to 02 8.4 in the same change. Ruling for docs/DECISIONS.md: the kept effect of fingerprint_mismatch, because every failed attempt must count also under a key (TM-T19). Ruling for docs/DECISIONS.md: attempts_remaining is a new problem member, because screen A8 (M4-37) shows the remaining attempts and no other field carries them. f. Prefix match: assign session_users through the assignment function of M2-39 (pointer /session_users/<i>/os_user; a name not in reported_session_users gives 400 validation_failed with errors[].code session_user_not_on_host; a name that belongs or belonged to another person on this host, two entries for one person or one for a person who already has a valid assignment gives 409 conflict with reason: session_user_taken); then declare llm_credentials and credential_holder_id through the declaration function of M2-40, which checks I6 before the commit (409 i6_violation with its deny entry in an own transaction, 03 6.8). Any of these failures rolls the whole unit back: no assignment, no declaration, no state change, fingerprint_failures unchanged. g. Then declared_server_host, confirmed_by, confirmed_at, fingerprint_failures = 0, state active through the M2-39 transition, version + 1; host.updated; audit allow; commit; 200 Host with ETag. h. Ruling for docs/DECISIONS.md: the fingerprint is checked before anything else of the body that needs the database, because a wrong host must never reach an assignment or a declaration. One confirm audit entry, plus the entries the reused M2-39 and M2-40 functions write (session_user, llm_credentials), all with the same correlation_id. A replay with the same Idempotency-Key gets the stored answer (06 7 point 2 stores 400, 409 and 412), so a network retry never burns a second attempt; a new attempt needs a new key.

  8. Fallback after too many failures. State pairing through the M2-39 transition; noise_public_key, auth_public_jwk and auth_jkt set to NULL; key_fingerprint kept (Design note 9); pairing_code_hash = the hash of a fresh code from PairingCodes.New that is never shown, logged or returned, with pairing_expires_at = now, because 03 demands a hash exactly in pairing and no code may work until the owner issues a new one; fingerprint_failures = 0; version + 1; host.updated with key_changed = true; attempts_remaining = 0. A later confirmHost gives 409 invalid_state_transition; the owner calls issueHostPairingCode. Ruling for docs/DECISIONS.md; update 24 3.2.

  9. Retained fingerprint and key reuse. Dropping keys sets the three key columns to NULL and keeps key_fingerprint (a hash of the dropped keys, not a key; 03 requires it only in confirming, active and disabled and does not forbid it in pairing). The Host mapper and the host.updated payload omit key_fingerprint and noise_public_key while noise_public_key is NULL. pairHost compares the new fingerprint with the retained one: equal gives 409 conflict with reason: host_key_reused; the audit entry of host.pair names both. Honest limit for the report: the server keeps no dropped key, so a host service that keeps one of its two keys is not detected; M2-42 generates both keys anew at every pair. Ruling for docs/DECISIONS.md; update 24 3.3.

  10. authz. host.pair: Source = the 05 text public (valid pairing code), expression Public (like the refresh-token text in M1-28); the code is proven by the use case before authorize runs, as M1-28 does for refresh tokens. The visibility row host (M2-39) becomes "effective owner, the holder of the host, a grant terminal, or any principal when the requirement of the action is exactly Public" (only host.pair today). Remove host.pair from the deferred golden list in the same change; VerifyRegistrations then accepts the route (AZ-P1). Ruling for docs/DECISIONS.md: the visibility clause.

  11. Re-pairing (POST /api/v1/hosts/{host_id}/pairing-code, host.manage, step-up, rl.admin, x-idempotency-exempt: auth_flow): load, authorize as in note 7b, reason as in note 7c; one rights-change unit as in note 7d; RepairEffects(state): retired gives 409 invalid_state_transition; pairing replaces the code only; confirming, active and disabled also drop the keys; active also ends every session of the host that is not closed with close_reason = 'host_rekeyed' through the M2-39 function in the same transaction; disabled also clears disabled_reason and disabled_at (the only way out of duplicate_connection, 24 3.4). Then issueCode, state pairing, version + 1, host.updated with key_changed = true exactly when keys were dropped (ruling for docs/DECISIONS.md: "dropped stored keys" is the one condition for key_changed), and 201 HostPairingCode with Cache-Control: no-store. A repeated call issues another code and invalidates the previous one (D-T37). key_generation rises only at the next pairHost (ruling for docs/DECISIONS.md: it numbers the stored keys that devices pin with noise_public_key, 24 5.5; update 24 3.3). Audit details (auditing.Attrs; fingerprints as auditing.Code in compact lower-case form, 52 characters of [0-9a-z], which matches the code pattern of SEC-AUD-5; if the merged length bound of auditing.Code is below 52, raise the named constant with its test and list it in the report): host.manage pairing_code: {host_id, operation, state_before, state_after, old_fingerprint (when keys were dropped), key_generation, sessions_ended, reason}; host.manage confirm: {host_id, operation, result (confirmed, fingerprint_mismatch, fingerprint_max_attempts), fingerprint, key_generation, attempts_remaining (on a mismatch), llm_credentials, credential_holder_id, declared_server_host, reason}; host.pair allow: {host_id, fingerprint, old_fingerprint (when one was retained), key_generation, platform}. Never the code, its HMAC, a key, a path or a working directory name. Ruling for docs/DECISIONS.md; update 24 10.1.

  12. Configuration. terminal.report_label_max_chars and terminal.report_max_entries are new keys of chapter 24 (Place Server); M2-43 reuses them for report. Start checks in configchecks.go: terminal.fingerprint_confirm_chars between 1 and the length of the compact fingerprint (a format constant), terminal.fingerprint_max_attempts at least 1, else a start error naming the key. Ruling for docs/DECISIONS.md.

  13. Locks, events, concurrency. Lock order 03 6.3: permission_version (through txn.RightsChange), then the host row, then event_seq and audit_seq; several hosts never in one unit. Two parallel pairHost with one code give one 201 and one 401; confirmHost racing issueHostPairingCode ends in one consistent order (either active then pairing, or pairing and the confirm answers 409 invalid_state_transition or 412 version_conflict); two parallel confirmations give one 200. Events go only through the M2-39 writer and eventlog.Appender; host.updated never carries a key, the code or its HMAC.

  14. Rulings for docs/DECISIONS.md, one row each with reason: notes 1, 2, 3e (rights version at host.pair), 3h (pairing_expires_at NULL), 4, 5 (only if 24 4.3 lacks the details), 6, 7e (attempts_remaining; KeepEffectOnFinal for fingerprint_mismatch), 7h, 8, 9, 10, 11 (key_changed, key_generation, audit details), 12, and the decision not to build terminal.sweep here. Facts already in the spec (code format, one-time use, fingerprint_mismatch, session_user_taken, I6 at confirmation) are not repeated there.

Tests (write them first)

Unit:

  • internal/identity/domain/apitoken: TestFormat_RoundTrip and TestParse_Rejects of M1-44 extended to the kind hpc; TestFormat_ChecksumVectors with the new vector (random part EXAMPLEhostpairingcode000000000000000000000, checksum 4SigQv).
  • internal/identity: TestHostPairingCode_MintAndHash (kind hpc, length 60, valid checksum, Hash equals HMAC-SHA-256 with the test pepper over the full string, the secret prints [redacted] through String, Format and MarshalJSON); TestHashHostPairingCode_RejectsWithoutLookup (one changed checksum character, kinds agt, app, pat and whi, a random part of 42 characters: ErrTokenMalformed, the repository fake counts zero calls); TestAuthenticator_RejectsPairingCode (Authorization: Bearer with a generated hpc code gives 401 unauthenticated without a token lookup); TestHostPairingCode_MatchesGitleaksRule (reads .gitleaks.toml from the repository root, extracts the regex and keywords of the rule bothy-api-token, and asserts that 100 codes from NewHostPairingCode match the regex and contain the keyword bothy_hpc_; gitleaks uses Go's regexp, so the engine is the same).
  • internal/terminal/domain: TestFingerprint_KnownVector (the vector of Design note 5); TestFingerprint_CrockfordTable (the RFC 4648 inputs in the Crockford alphabet: empty to empty, f to CR, fo to CSQG, foo to CSQPY, foob to CSQPYRG, fooba to CSQPYRK1, foobar to CSQPYRK1E8); TestFingerprint_RejectsWrongLength; TestFingerprint_KeyOrderMatters; TestFingerprint_HostVectors only under the condition of Design note 5.
  • internal/terminal/domain: TestParseHostKeys (valid pair; padded base64url, 31 and 33 bytes, a low-order X25519 point such as 32 zero bytes: invalid_key; JWK with d, with kid, with kty EC, with crv X25519, x of 31 bytes: the codes of Design note 4b; auth_jkt equals the RFC 7638 thumbprint of {"crv","kty","x"}); TestValidatePairReport (empty session_users; two names that differ only in case; a name over host.os_user_max_chars; a name with /; a working directory label with a bidi character; a label over terminal.report_label_max_chars; one entry over terminal.report_max_entries in each list; duplicate profile names; host_version v1.0.0 and 1.0; an empty profile list passes); TestNormalizeFingerprintPrefix (lower case accepted; one character short and long; -, I, L, O, U: invalid_format); TestPrefixMatches; TestNextAttempt (every value from 0 to terminal.fingerprint_max_attempts, remaining count, fallback exactly at the maximum, a match resets); TestRepairEffects (each state of 24 3.1: allowed or not, drops keys, ends sessions, clears disabled_reason).
  • FuzzParseHostKeys and FuzzNormalizeFingerprintPrefix: any input gives findings or a valid result, never a panic, never a key that is not 32 bytes.
  • internal/authz: TestHostPairRequirement (anonymous principal and an authenticated person on a host resource: allow for host.pair; the same host stays invisible to them for host.read); TestDeferredActionsGolden without host.pair; the source test of M1-28 for the text public (valid pairing code).
  • internal/kernel/errs and internal/platform/httpserver: the problem shape test of M1-18 with attempts_remaining present only on fingerprint_mismatch, including the value 0.
  • internal/platform/httpserver (idempotency stage of M1-20): TestIdempotency_KeepEffectOnFinal: only a marked final error commits; a final 409 marked with KeepEffectOnFinal commits the handler's write together with the stored answer, and its replay returns that answer with Idempotent-Replay: true without running the handler; the same 409 without the marker rolls the write back and is stored by SaveFinal; a marked 401 or 5xx still rolls back and stores nothing.
  • internal/terminal/app: TestTMT20_WrongChecksumNoDatabase (TM-T20, "wrong checksum without database access"): PairHost with a fake unit of work and a fake repository, both counting calls; a code with one changed checksum character and a code of kind agt give 401 unauthenticated with zero Do calls and zero repository calls, and exactly one RecordDenial with reason unauthenticated.
  • internal/wire: TestConfig_FingerprintKeysChecked (terminal.fingerprint_confirm_chars of 0 and above the fingerprint length, terminal.fingerprint_max_attempts of 0: start error naming the key).

Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain on 127.0.0.1:0, fake clock, step-up through the M1-35 helper, every answer checked against openapi.yaml; world: Robin is the owner, Alex a second owner through owner.add, Sam a person without owner rights; host "Build server" created by Robin with createHost, assigned to Sam where a test says so; the test plays the host service with fresh X25519 and Ed25519 keys and reports the session users bothy-s-robin, bothy-s-alex and bothy-s-sam, each with the working directory label "Project A", and the profiles shell and claude):

  • TestPairingCode_OnlyHMACStored ("only the HMAC of the code stored"): after createHost and after issueHostPairingCode the full code appears in no row of hosts, audit_events, event_log, idempotency_keys and river_job and in no captured log line; pairing_code_hash equals the HMAC of the code; both answers carry Cache-Control: no-store.
  • TestPairHost_HappyPath: 201 HostPairResult with host_id, key_fingerprint equal to ComputeFingerprint of the sent keys and instance_id; the host is confirming; noise_public_key, auth_public_jwk (exactly three members), auth_jkt, key_fingerprint, key_generation = 1, paired_at, platform, reported_os_version, reported_host_version, reported_profiles stored; pairing_code_hash and pairing_expires_at NULL; the rights version raised by one; one host.updated without key_changed; one audit allow host.pair with the details of Design note 11.
  • TestPairHost_StoresReportedSessionUsers (PLAN row: "pairHost stores the reported names of the session users in hosts.reported_session_users"): the column equals the reported list with names and working directory labels in their order, and getHost shows it in reported_session_users.
  • TestTMT20_PairingCodeRejections (TM-T20): the same code a second time; a code after the clock passed terminal.pairing_code_ttl; a code with one changed checksum character; a code replaced by issueHostPairingCode while the host was pairing; an unknown well-formed code: each gives 401 unauthenticated, the bodies are equal except correlation_id, the host is unchanged, and after flushing the recorder the audit holds aggregated denials of host.pair with reason unauthenticated.
  • TestPairHost_ReportRules: one integration case per rule group of Design note 4 gives 400 validation_failed with the pointer and errors[].code named there, and the same code then pairs successfully (the code stays valid after a 400).
  • TestPairHost_RejectsPrivateJWKMember: a JWK with d gives 400 validation_failed with pointer /auth_public_jwk/d and errors[].code unknown_member; nothing of it appears in any row or log.
  • TestPairHost_KeyInUse: the Ed25519 key of another host gives 409 conflict with reason: host_key_in_use; the code stays valid.
  • TestConfirmHost_HappyPath: Robin with step-up confirms with the lower-case prefix, llm_credentials = organization, declared_server_host = true, assignments Robin to bothy-s-robin and Sam to bothy-s-sam: 200 Host in active with ETag; confirmed_by, confirmed_at, the declaration columns, declared_server_host and two host_session_users rows stored; fingerprint_failures = 0; the rights version raised; host.updated; the confirm audit entry; Robin's auth_sessions.step_up_consumed_at unchanged.
  • TestTMT19_FingerprintMaxAttempts (TM-T19), run twice, once with a fresh Idempotency-Key per attempt and once without the header: wrong prefixes up to terminal.fingerprint_max_attempts: every answer is 409 fingerprint_mismatch with attempts_remaining counting down, version stays the same until the last attempt, the same If-Match works for every attempt; the last answer carries attempts_remaining 0, the host is pairing, the three key columns are NULL, the code used before gives 401 unauthenticated, a confirmation with the right prefix gives 409 invalid_state_transition, host.updated carries key_changed = true; a new code from issueHostPairingCode and new keys pair again (key_generation = 2), and the right prefix confirms.
  • TestConfirmHost_WrongPrefixFormatNotCounted: a prefix with O and one of the wrong length give 400 validation_failed with errors[].code invalid_format; fingerprint_failures unchanged; the first mismatching request with an Idempotency-Key gives 409 fingerprint_mismatch and fingerprint_failures = 1, and its replay with the same key returns the stored 409 while fingerprint_failures is still 1.
  • TestTMT28b_ConfirmSessionUserTaken (TM-T28 (b) over confirmHost): (1) host active with Sam on bothy-s-sam, updateHost ends Sam's assignment, re-pairing and pairing, then a confirmation assigning bothy-s-sam to Alex; (2) one confirmation with Sam and Alex both on bothy-s-sam; (3) one confirmation with Sam on bothy-s-sam and on bothy-s-alex; (4) after a re-pairing in which Robin kept his valid assignment, a confirmation giving Robin bothy-s-alex: each gives 409 conflict with reason: session_user_taken; nothing changed (host confirming, no new row, no declaration, fingerprint_failures unchanged).
  • TestTMT28e_ConfirmNameNotReported (TM-T28 (e) over confirmHost): a confirmation assigning bothy-s-other, which the host did not report, gives 400 validation_failed with errors[].code session_user_not_on_host and pointer /session_users/0/os_user; nothing is stored.
  • TestConfirmHost_I6Violation: while the host is confirming, Robin grants terminal on it to Sam; a confirmation with personal and holder Robin gives 409 i6_violation, the host stays confirming, the deny entry exists after the rollback; the same body with organization gives 200.
  • TestConfirmHost_RightsAndPreconditions: no If-Match gives 428 precondition_required; a stale one 412 version_conflict with current; host pairing and active give 409 invalid_state_transition; Sam as assignee gives 403 forbidden with reason: owner_only; Sam on a host that is not his gives 404 not_found like an unknown ID; Robin without step-up gives 401 step_up_required.
  • TestIssuePairingCode_Rights: Sam as assignee gives 403 forbidden with reason: owner_only; without step-up 401 step_up_required; retired gives 409 invalid_state_transition; a reason over audit.reason_max_chars gives 400 validation_failed with errors[].code too_long.
  • TestRekey_FromActive (PLAN row: "re-pairing discards the keys immediately, ends sessions with host_rekeyed, ... host.updated with key_changed to all owners"): an active host without assignee, with an active and a detached session of Robin seeded through the M2-39 test helper (or a test-only insert in the test package of internal/terminal/adapters/postgres if M2-39 has none); issueHostPairingCode gives 201; in the same transaction the host is pairing, the three key columns are NULL, both sessions are closed with host_rekeyed, the rights version is raised; getHost shows no key_fingerprint and no noise_public_key; one host.updated with key_changed = true, whose receive check authorize(principal, host.read, host) allows Robin and Alex (every owner) and gives not_found for Sam; the event row validates against HostUpdatedEvent and holds no key, code or HMAC.
  • TestRekey_AuditNamesBothFingerprints (PLAN row: "audit with the old and the new fingerprint"): the pairing_code entry names the old fingerprint; after pairHost with new keys, the host.pair entry names old_fingerprint and fingerprint (different values) and key_generation = 2.
  • TestPairHost_RejectsReusedKeys: after a re-pairing, the old key pair gives 409 conflict with reason: host_key_reused and the code stays valid; a new pair gives 201.
  • TestRekey_FromConfirmingDisabledAndPairing: from confirming the keys are dropped and key_changed = true; from disabled with duplicate_connection (set by a test-only update of the row, because only M2-43 sets this reason) the host is pairing with disabled_reason and disabled_at NULL; from pairing only the code is replaced, without key_changed, and the old code gives 401 unauthenticated.
  • TestPairingNeverLeaksSecrets: over the scenarios above, no pairing code, no HMAC of a code, no private JWK member, no public key value and no working directory label appears in audit_events, event_log payloads, problem bodies or captured logs; the audit details contain only the keys of Design note 11.
  • TestPairingConcurrency: two parallel pairHost with one code give one 201 and one 401; two parallel confirmations give one 200 and one 412 version_conflict or 409 invalid_state_transition; confirmHost racing issueHostPairingCode never leaves the host active with NULL keys.
  • TestPairingRoutes_AZP1: issueHostPairingCode, pairHost and confirmHost are registered with their catalogue actions and rate-limit classes (rl.admin, rl.public_auth, rl.admin), and the server starts.
  • TestCreateHost_IssuesCode: only in case c of Design note 2.

Python (tools/ci/tests/test_gitleaks_rules.py):

  • test_pairing_code_with_valid_checksum_is_detected (PLAN row: "make gitleaks detects a generated code in the test run"): builds a code of kind hpc at runtime with secrets.token_bytes(32), Base62 and zlib.crc32, writes it to a temporary file and asserts that the pinned gitleaks binary with .gitleaks.toml reports the rule bothy-api-token.
  • test_checksum_vectors_match_go (M1-44) recomputes the new hpc vector.

Contract: responses of the three operations validate against HostPairingCode, HostPairResult, Host and Problem; problems carry code and, where named above, reason, errors[] or attempts_remaining.

Red team focus of the milestone (PLAN 8 "Red team": "pairing with an intercepted or guessed code") is covered by TestTMT19_FingerprintMaxAttempts, TestTMT20_PairingCodeRejections, TestTMT20_WrongChecksumNoDatabase, TestPairHost_RejectsReusedKeys, TestRekey_FromActive and TestRekey_AuditNamesBothFingerprints; name them in the red team request.

Run FuzzParseHostKeys and FuzzNormalizeFingerprintPrefix once locally with go test -run=^$ -fuzz=<target> -fuzztime=<duration of your choice> and report duration and result; commit any crashing input as a seed after the fix.

Commands that must pass, from the repository root:

  • cd services/server && go test -race -count=1 ./internal/terminal/... ./internal/identity/... ./internal/authz/... ./internal/kernel/... ./internal/platform/httpserver/... ./internal/wire/... ./api/...
  • cd tools && uv run --locked pytest ci/tests/test_gitleaks_rules.py
  • make gitleaks
  • make codegen-check, make arch, make api-lint, make api-checks, make api-tables, make register-check
  • make config-reference then make config-doc-check
  • make adr-index
  • make docs
  • make check

Acceptance criteria

  • issueHostPairingCode, pairHost and confirmHost are enabled and registered; AZ-P1 passes; host.pair is a real requirement and no longer in the deferred golden list.
  • Pairing codes have the form of ADR 0061 with kind hpc; only their HMAC is stored; they are shown exactly once; the authenticator never accepts one; the gitleaks rule detects a code generated in the test run.
  • TM-T20: a code used twice, an expired code and a code with a wrong checksum are rejected with 401 unauthenticated, the wrong checksum without database access.
  • pairHost stores both public keys, the fingerprint, the raised key_generation, platform, versions, profiles and the reported names of the session users in hosts.reported_session_users, and the host is confirming.
  • TM-T19: a wrong fingerprint gives 409 fingerprint_mismatch with attempts_remaining; after terminal.fingerprint_max_attempts failures the host is pairing and its keys are dropped, with and without Idempotency-Key.
  • TM-T28 (b) and (e) over confirmHost: a taken name or a second valid assignment gives 409 conflict with reason: session_user_taken, a name the host did not report 400 validation_failed with errors[].code session_user_not_on_host, and nothing is stored; I6 is checked in the same transaction.
  • Re-pairing drops the keys at once, ends the sessions with host_rekeyed, audits old and new fingerprint, refuses a re-sent key pair, and writes host.updated with key_changed that every owner receives.
  • No pairing code, HMAC or key material in events, logs, audit details or problem bodies; no JSON null; the spec, openapi.yaml, ADR 0061, the threat model and DECISIONS are updated as in Scope.
  • make check is green.

Definition of done

  • Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
  • READMEs of terminal, identity and authz updated (pairing, confirmation, re-pairing, the shared issuer, the kind hpc, the requirement of host.pair).
  • Rulings of Design note 14 recorded in docs/DECISIONS.md section 10 (one row each with reason); the changes to 02, 06, 08, 24, openapi.yaml, ADR 0061 and the threat model of the Scope made in the same change.
  • services/server/CHANGELOG.md entry under 'Unreleased'.
  • An ADR only if you take an architecture decision beyond this brief (MADR 4 as in docs/adr/0000-template-madr-4.md, headings and text in English, next free number, make adr-index).
  • make check green.
  • An independent code review over the diff, then the red team gate (docs/quality/red-team-gate.md, verdict JA or NEIN); on NEIN fix with a test per finding and have the delta checked again. Record both verdicts.
  • Land the change as AGENTS.md ("How work lands on main") describes: a pull request from this branch, squash-merged only when review, red team and make check, ci-ok and the review threads are green; then git worktree remove.

Report

Write to {{REPORT_PATH}}:

  1. First line exactly Verdict: DONE or Verdict: BLOCKED <reason>.
  2. Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of make check.
  3. Review and red team verdicts and the findings fixed.
  4. What is not proven (at least: a host service that keeps one of its two keys at re-pairing, Design note 9; the fingerprint against services/host if M2-38 was not merged).
  5. Follow-ups that could not be done in this scope, each with its reason, and spec gaps found (at least: closing the connections of a re-paired host with 4110 in M2-43; the job terminal.sweep and its clause on expired codes, which no ticket row names; which case of Design note 2 applied).

Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.