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, writesreport), 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.mdis 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-uplistEvents), M2-38 (host module and itsfingerprint.json), M2-27 (which may or may not have added thewhiminting methods to the identity port first). docs/spec/24-terminal.md(English, normative): 1 "Purpose and limits"; 2.1 "Overview"; 2.2 "Host servicebothy-host" points 1, 2, 3 and 5 of "One session user per person and host, never shared" (assignment at confirmation, names reported athost.pair,400 validation_failedwitherrors[].codesession_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 ofduplicate_connectionis 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 onpermission_version(a transaction that changes a host'sstate,llm_credentials,credential_holder_id,declared_server_host,reported_session_usersor rows ofhost_session_usersraises it); 4.14hosts(every column, every check, the unique partial indexes onauth_jktandpairing_code_hash, the index onpairing_expires_at),host_session_users(unique rules,409 conflictwithreason: session_user_taken),terminal_sessions(close_reasonhost_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) anddocs/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 rowshost.read,host.manage,host.pair(Permissionpublic (valid pairing code), Audityes, Permissionsno,rl.public_auth) and the paragraph after the table; 6 rowhost.updated(receive actionhost.read,key_changedat 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.updatedis durable), 2.2 "Envelope".docs/spec/08-identity.md: 6.3 "Fresh step-up for the terminal" (only to keep it apart:host.manageuses 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_ageis a key of 03).docs/spec/07-permissions.md: 2.12 "Hosts and theterminalcapability", 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" (nonull, 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" (rowterminal: portHostswith "administration, pairing, declaration"; rowidentity), 4.4 "Allowed dependencies" (terminalmay useinstance,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" (rowterminal.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: operationsissueHostPairingCode,pairHost,confirmHost, and for contextcreateHost,getHost,updateHost,setHostLlmCredentials,setHostState; schemasHostPairingCode,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), parametersHostIdPath,IdempotencyKey,IfMatchRequired.docs/api/asyncapi.yaml: messageevent.host.updated(x-receive-action: host.read); channelhostControl(x-close-codes4110host_unpaired, for the M2-43 part).- ADRs:
docs/adr/0061-token-format-with-a-fixed-prefix-and-checksum.md(form with kindhpc, 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 throughkernel/jose),0017,0020,0044(product name in the prefix),0053(nonull),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 athost.pair, accepted atconfirmHost), 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.md3 "Tailnet policy (required)" for the network point.docs/observability/signals.md4.3a "Terminal" (this ticket adds no metric)..gitleaks.tomlrulebothy-api-token(already listshpcand the keywordbothy_hpc_);tools/ci/tests/test_gitleaks_rules.py;CONTRIBUTING.md(markerEXAMPLEfor 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 rejectingwhi),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.RecorderwithRecordandRecordDenial,Attrsvalue 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/joseParseJWK,Thumbprint),M1-35-identity-step-up.md(step-up test helper). Briefs of M2-39 and M2-40, if they exist indocs/briefs/when you start. - Code on the base branch:
internal/terminal(M2-39: portHosts,domainwith the state machine of 24 3.1,appwithcreateHost,updateHostand its session user assignment function, the function that ends all sessions of a host, thehost.updatedwriter and theHostmapper,adapters/postgres,adapters/httpapi; M2-40: the declaration of the LLM login behindsetHostLlmCredentialswith the I6 check),internal/identity(domain/apitoken, portTokens, 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):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- 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).
- 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.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - 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. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/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:
- The pairing code has the form
bothy_hpc_<random>_<checksum>: 256 bits fromcrypto/randin Base62 as exactly 43 characters, CRC-32 (IEEE) over the random part as exactly 6 Base62 characters, generated bydomain/apitokenof M1-44 extended by the kindhpc(ADR 0061 point 1, 24 3.2, D-T32). - Only the HMAC-SHA-256 of the code with the pepper of
auth.token_pepper_refis stored (hosts.pairing_code_hash); the code is shown exactly once inHostPairingCodeand appears in no log, audit entry, event, job, idempotency row or problem body (03 point 9, 24 3.2, SEC-LOG-1). - A code works once and only before
terminal.pairing_code_ttlends; a used, replaced, expired or unknown code and a code with a wrong checksum give401 unauthenticatedwith the same body; a wrong form or checksum is rejected without any database access (TM-T20, ADR 0061 point 2). pairHost(host.pair: public with a valid code, Audityes,rl.public_auth) stores both public keys, platform, versions, profiles and the reported session users inhosts.reported_session_users, computeskey_fingerprint(24 4.3), raiseskey_generationand moves the hostpairing -> confirming(24 3.1 and 3.2, D-W-63).confirmHost(host.manage: owner, step-up, Audit, Permissionsyes,Idempotency-Key,If-Match) compares the typed prefix of the fingerprint; a mismatch gives409 fingerprint_mismatchand the host staysconfirming; afterterminal.fingerprint_max_attemptsfailures it falls back topairingand its keys are dropped (TM-T19, 24 3.2, D-T31).- With the right prefix
confirmHostdeclaresllm_credentialsand the credential holder with I6 in the same transaction (409 i6_violation, 03 6.8, 24 7.3), recordsdeclared_server_host(24 2.3), assigns session users only from the reported names (400 validation_failedwitherrors[].codesession_user_not_on_host) and never a name that belongs or belonged to another person or a second valid assignment of one person (409 conflictwithreason: session_user_taken), stores nothing on any of these failures (TM-T28 (b) and (e), T12, D-W-63), and moves the hostconfirming -> active. issueHostPairingCode(re-pairing) frompairing,confirming,activeordisabledissues a new code, drops the stored keys at once, ends the sessions of the host withhost_rekeyedin the same transaction, audits the old fingerprint and writeshost.updatedwithkey_changed = true, which every owner receives throughhost.read; the nextpairHostraiseskey_generationand audits old and new fingerprint (24 3.3, SEC-TERM-8).- Every transaction of this ticket that changes a host's state, its declaration,
declared_server_host,reported_session_usersor rows ofhost_session_usersraises the rights version first (03 4.3 and 6.3, 05 rule 1.7). - The kind
hpcis never accepted as an API token and never issued bycreateToken; the gitleaks rulebothy-api-tokendetects a generated code (SEC-SEC-4, D-T32, PLAN row). - 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 theHostrepresentation shows (ADR 0053, 05 6 rowhost.updated, SEC-AUD-5).
Terminal security requirements (D-W-101):
- Rights per machine: the additional right
terminalon the targethost, SR10, SR11 with I6 and SR12 (07 "Hosts and theterminalcapability"). M2-39 ownshost.readandhost.manage(AZ-M60), M2-40 ownsterminal.*, SR10, SR11, SR12 and I6 atgrant.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.pairbecomes a real requirement (Public, the code is the proof) instead of a deferred entry (TestHostPairRequirement). - 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
freshand 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 ofhost.manage(auth.step_up_max_age), and a confirmation never consumes the fresh step-up of the terminal (TestConfirmHost_HappyPathassertsauth_sessions.step_up_consumed_atunchanged;TestConfirmHost_RightsAndPreconditionsasserts401 step_up_requiredwithout step-up). - 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
tailscalereaches 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 byrl.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. - Audit without content: this ticket writes
host.manageandhost.pairentries 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. - 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_timeoutplusterminal.idle_graceandterminal.max_session_duration(TM-T11). The only session end this ticket causes ishost_rekeyedat re-pairing, through the session-ending function of M2-39 (TestRekey_FromActive). - Service user and session users without administrator rights (T12, HD rules): M2-42 builds
install,doctorwith 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:pairHostaccepts only a non-empty list of distinct session user names that match the pattern andhost.os_user_max_chars(mirrors HD1,TestPairHost_ReportRules), andconfirmHostgives each person their own, never reused name (TM-T28 (b) and (e)). - 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_MatchesGitleaksRuleandtest_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 andhost.updatedwithkey_changedto 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.jsonwith the known vector of Design note 5.internal/terminal/domain/pairing.go: pure functionsParseHostKeys(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) AttemptOutcomeandRepairEffects(state HostState) (RepairPlan, error)(Design notes 3, 6 and 8).FieldErroris the type the mergedkernel/errsuses forerrors[].internal/terminal/app/pairing.go:IssuePairingCode(the shared issuer of Design note 2),PairHost,ConfirmHost; outgoing portPairingCodes { New(ctx context.Context) (identity.HostPairingCode, error); Hash(ctx context.Context, raw string) ([]byte, error) }bound to the identity port ininternal/wire.internal/terminal/adapters/postgres/queries/pairing.sql: find a host bypairing_code_hashwithout lock; lock one hostFOR UPDATE; set a new code; drop the keys; store a pairing; incrementfingerprint_failureswithout changingversion; confirm. Through the M2-39 transition function where it exists.- Fuzz targets
FuzzParseHostKeysandFuzzNormalizeFingerprintPrefixnext topairing.go.
Change:
internal/identity/domain/apitoken(M1-44): the kindhpcnext toagt,app,patandwhi; the authenticator keeps rejectingwhiand now alsohpcwithout a database call;createTokennever deriveshpc.testdata/checksum_vectors.json: one vector of kindhpcwith the random partEXAMPLEhostpairingcode000000000000000000000and the checksum4SigQv(computed independently for this brief; the random part carries theEXAMPLEmarker).internal/identityportTokens(M1-44):NewHostPairingCode(ctx context.Context) (HostPairingCode, error)withHostPairingCode{Secret <the redacting secret type of M1-44>; Hash []byte}, andHashHostPairingCode(ctx context.Context, raw string) ([]byte, error)returningErrTokenMalformedwithout any database call for every value that is not a well-formed token of kindhpc(form, checksum, kind). If M2-27 already addedErrTokenMalformed, reuse it; otherwise create it with a doc comment. Both usedomain/apitokenand the HMAC hasher ofauth.token_pepper_ref(Design note 1).internal/terminalportHosts(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)withIssuePairingCodeCmd{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.Principalstands for the request principal type ofkernel/actor.internal/terminal/app(M2-39):createHostissues its code throughIssuePairingCode's shared function (Design note 2); theHostmapper omitskey_fingerprintandnoise_public_keywhile the host has no keys (Design note 9).internal/terminal/adapters/httpapi: strict-server methods ofissueHostPairingCode,pairHost,confirmHost;Cache-Control: no-storeset by the handler on every answer of the three operations (SEC-HDR-2, whether or not M1-21 is merged);ETagon the200ofconfirmHost.internal/kernel/errsandinternal/platform/httpserver(M1-18): an optional problem memberattempts_remainingcarried by the error offingerprint_mismatchand written byWriteProblem(Design note 7); the test of the problem shape per code.internal/kernel/idempotencyand the idempotency stage ininternal/platform/httpserver(M1-20):KeepEffectOnFinal(ctx)next toMarkOneTimeSecret; for a marked final error answer the stage stores it withStore.Completein 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 ofhost.pairisPublicinstead ofDeferred(rowpublic (valid pairing code)of the text mapping); the golden list of deferred entries withouthost.pair; the visibility rowhostadditionally allows a principal for an action whose requirement isPublic(Design note 10).internal/wire: bindings ofPairingCodes; configuration keys this ticket is the first consumer of, amongterminal.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 keysterminal.report_label_max_chars(KindInt, minimum 1, default100) andterminal.report_max_entries(KindInt, minimum 1, default64) with the chapter constant of24-terminal.mdinconfigchapters.go; the start checks of Design note 12 inconfigchecks.go.api/operations.yaml: moveissueHostPairingCode,pairHostandconfirmHost(andcreateHost, if stilllater, Design note 2) fromlatertoenabled; 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 newhpcvector..gitleaks.tomlalready listshpc; change it only if the test proves it wrong.docs/spec/24-terminal.md(English, like the chapter): 3.2 (HMAC input,401 unauthenticatedfor every redemption failure with the same body, form and checksum without database access, the key and report rules of Design note 4 with theirerrors[].codevaluesinvalid_key,unknown_member,empty,duplicate,too_long,limit_reachedandinvalid_format, the prefix rules of Design note 6,attempts_remaining, the fallback of Design note 8); 3.3 (key_generationrises athost.pair,key_changedexactly when stored keys are dropped, the retained fingerprint and409 conflictwithreason: host_key_reused); 10.1 (details ofhost.managepairing_codeandconfirmand ofhost.pair, Design note 11); 13 (rowconflict: reasonshost_key_in_useandhost_key_reused; rowfingerprint_mismatch:attempts_remaining); 17 (rowsterminal.report_label_max_charsandterminal.report_max_entries).docs/spec/08-identity.md12 "Form": the kindhpc(pairing code of a host, issued only throughhost.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 namesattempts_remainingatfingerprint_mismatch.docs/spec/02-architecture.md4.4 rowterminal: the reason column names the pairing code (format and HMAC throughidentity); 8.4: the exception of Design note 7e (a final error answer marked withKeepEffectOnFinalkeeps its effect and is stored in the same transaction; only409 fingerprint_mismatchofconfirmHostis marked).docs/api/openapi.yaml:Problem.attempts_remaining(integer, minimum 0, "present withfingerprint_mismatch;0means the host fell back topairingand needs a new pairing code"); thex-problem-codesdescription offingerprint_mismatch; the descriptions ofissueHostPairingCode(allowed states,409 invalid_state_transitionforretired,key_changedwhen stored keys are dropped),pairHost(the401cases, the409 conflictreasonshost_key_in_useandhost_key_reused, the key and report rules with theirerrors[].codevalues) andconfirmHost(prefix rules witherrors[].codeinvalid_format,attempts_remaining, the failure counter does not changeversion);Host.key_fingerprintandHost.noise_public_key"absent while the host has no keys". Thenmake api-lint,make api-checks,make api-tablesand the server code generation.docs/adr/0061-token-format-with-a-fixed-prefix-and-checksum.md"Confirmation": the tests of the kindhpc;make adr-index.docs/security/threat-model.md6.7 row T-TM-06, column "Tests": add the test names of requirement 27.docs/operations/configuration.md: regenerate withmake config-reference.- READMEs of
internal/terminal(pairing, confirmation, re-pairing, the shared issuer),internal/identity(kindhpc, the two port methods) andinternal/authz(host.pair);services/server/CHANGELOG.mdunder "Unreleased";docs/DECISIONS.mdsection 10 rowsD-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 ofhost.readandhost.manage, thehost.updatedwriter, the session-ending function and the assignment function with TM-T28 (b) and (e) throughupdateHost(M2-39);createHostitself (M2-39), except routing its code through the shared issuer (Design note 2). terminal.*rights, SR10, SR11, SR12, I6 atgrant.create,setHostLlmCredentials, step-upfresh, the hintterminal_not_effectiveand 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),doctorandinstall(M2-42).- The control connection,
report(which writeshosts.reported_session_usersafter pairing), closing the connections of a re-paired host with4110, presence and the version check4114(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
report403 terminal_deniedwithreason: no_session_user, no4104") 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 stayspairing(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 ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(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
hoststable, nohost.managerequirement entry or no I6 check in a declaration function, a dependency is missing: stop withVerdict: BLOCKED <reason>. - Pairing codes in tests are built at runtime; a committed code-shaped value carries
EXAMPLEin its random part (CONTRIBUTING.md). Never write a completebothy_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 forterminal.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¶
-
Code and hash (identity).
NewHostPairingCodegenerates a token of kindhpcwithdomain/apitoken(the constantbothystays exactly once, M1-44) and returns it in the redacting secret type of M1-44 together withHash = HMAC-SHA-256(pepper of auth.token_pepper_ref, full code string), the same derivation asapi_tokens.token_hash.HashHostPairingCodeparses (form, kindhpc, checksum) and returnsErrTokenMalformedbefore computing any HMAC for a wrong value. Ruling for docs/DECISIONS.md: one derivation for every token of ADR 0061; the prefixbothy_hpc_already separates the kinds, so no extra label is hashed. The authenticator of M1-44 treatshpclikewhi:401 unauthenticatedwithout a database call. -
Shared issuer.
IssuePairingCode's inner functionissueCode(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) andissueHostPairingCodeboth use it. Check the merged code first: a. M2-39 already mintshpccodes throughdomain/apitokenand the identity port: keep its names, add only what is missing and routeissueHostPairingCodethrough 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;createHostkeeps its behaviour and its tests. c.createHostis stilllateron the base branch: enable it here with the shared issuer, withTestCreateHost_IssuesCode, because it is the only way to create a host inpairing. Name the case in the report. Ruling for docs/DECISIONS.md: one issuer for both operations. -
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 apairing_codeoutside its pattern with400 validation_failed. b.PairingCodes.Hash:ErrTokenMalformed(wrong checksum, kind nothpc) gives401 unauthenticated; no unit of work is opened and no query runs; the denial goes toauditing.Recorder.RecordDenial(actionhost.pair, reasonunauthenticated, no resource), which aggregates it per IP pseudonym andaudit.deny_aggregate_window(R-AZ-04) and writes outside the request. c.ParseHostKeysandValidatePairReport(pure, no database): every finding is400 validation_failedwith a pointer into the body and leaves the code valid (Design note 4). d. Read the host bypairing_code_hashwithout lock (unique partial index). No row, orpairing_expires_at <= now:401 unauthenticatedwith the same body as in b and a denial throughRecordDenial(resourcehostwith its ID when a row matched). e. One unit of work withtxn.RightsChange{Action: host.pair, Resource: the host, Targets: [the host]}as M2-39 and M2-40 use it for a host (03 4.3 listsreported_session_users, so the rights version rises first, although 05 giveshost.pairPermissionsno; ruling for docs/DECISIONS.md). Lock the hostFOR UPDATEand check again thatpairing_code_hashequals the hash,state = 'pairing'andpairing_expires_at > now; otherwise roll back and answer as in d (a parallel redemption won). f.authorize(principal, host.pair, Resource{Kind: host, ID})givesallowthroughPublic(Design note 10). g.ComputeFingerprint; equal to the retainedkey_fingerprintof this host:409 conflictwithreason: host_key_reused(Design note 9), roll back, the code stays valid. h. Writenoise_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'sreportwrites the column the same way),pairing_code_hash = NULL,pairing_expires_at = NULL(ruling: the index onpairing_expires_atserves onlypairing), stateconfirmingthrough the M2-39 transition,version + 1. A unique violation onauth_jkt(the key belongs to another host, also a retired one) rolls back with409 conflictandreason: host_key_in_use. i.host.updatedthrough the M2-39 writer withoutkey_changed; audit allowhost.pair(Design note 11). Answer201 HostPairResult{host_id, key_fingerprint, instance_id}withinstance_idfrom theinstanceport (M1-15). A retry after a lost answer gets401 unauthenticated, because the code is used (D-T37); the operator asks the owner for a new code (M2-42 prints that hint). -
Key and report rules (
ParseHostKeys,ValidatePairReport; pointers relative to the body; ruling for docs/DECISIONS.md: these rules and theirerrors[].codevalues): a.noise_public_key: strict unpadded base64url (base64.RawURLEncoding.Strict()), exactly 32 bytes, elseinvalid_key; a low-order point isinvalid_keytoo:crypto/ecdhX25519().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 memberskty(OKP),crv(Ed25519) andx; any other member, the private memberdabove all, isunknown_memberat/auth_public_jwk/<name>;xmust decode strictly to 32 bytes, elseinvalid_key; parse it withkernel/jose.ParseJWKand takeauth_jktfromJWK.Thumbprint()(RFC 7638);auth_public_key_rawof the fingerprint is the decodedx. c.session_users: at least one entry (empty; a host service without session users fails HD1 and can never give a terminal); eachos_usermatches^[A-Za-z0-9_][A-Za-z0-9_.-]*$and has at mosthost.os_user_max_charscharacters (too_long); names are distinct without regard to case (duplicate); eachworkdirsentry is a non-empty display text (kernel/textcheck.DisplayText) of at mostterminal.report_label_max_charscharacters. d.profiles: names match the schema pattern, are distinct (duplicate) and at mostterminal.report_label_max_charscharacters long;display_nameis a display text of at most that length; an empty list is allowed (HD7 may drop every profile). e.profiles,session_usersand theworkdirsof one session user each have at mostterminal.report_max_entriesentries (limit_reached). f.host_versionis a SemVer 2.0.0 version without a leadingv(invalid_format);os_version, when present, a display text of at mostterminal.report_label_max_charscharacters. -
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 alphabet0123456789ABCDEFGHJKMNPQRSTVWXYZ, 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 bytes0x00to0x1fand auth key bytes0x20to0x3fgiveF0CC-6QS6-RF5D-3BSH-CAZQ-PXMR-J8RA-AHK5-1HF0-9S02-1GW6-BW3J-HK90. Ifservices/host/testdata/noise/fingerprint.jsonexists on the base branch (M2-38 merged), addTestFingerprint_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. -
Prefix rules (
HostConfirm.fingerprint_prefix, "without separators, case-insensitive"): upper-case it; it must have exactlyterminal.fingerprint_confirm_charscharacters, all from the Crockford alphabet, else400 validation_failedwith pointer/fingerprint_prefixanderrors[].codeinvalid_format, and the attempt does not count. A well-formed prefix is compared with the first characters ofCompactFingerprint(key_fingerprint)bycrypto/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. -
confirmHost(POST /api/v1/hosts/{host_id}/confirm,host.manage, step-up,rl.admin,Idempotency-Key,If-Match), in this order: a.428 precondition_requiredwithoutIf-Matchcomes from the precondition stage of M1-18; the schema validator rejects missing members. b. Load the host; unknown gives404 not_found.authorize(actor, host.manage, host): invisible gives the same404, a visible host without owner rights403 forbiddenwithreason: owner_only, no fresh enough step-up401 step_up_required(M1-28, M1-35). c. Body rules without the database:reason(kernel/textcheck, at mostaudit.reason_max_chars, else400 validation_failedwitherrors[].codetoo_long), the prefix of Design note 6, the declaration's own validation of M2-40 (credential_holder_idexactly withpersonal, as the 03 check demands). d. One unit of work withtxn.RightsChange{Action: host.manage, Resource: the host, Targets: [the host]}; lock the hostFOR UPDATE;versiondifferent fromIf-Match:412 version_conflictwithcurrent;state <> 'confirming':409 invalid_state_transition. e. Prefix mismatch:NextAttemptraisesfingerprint_failureswithout changingversion(the counter is not part ofHost, so the owner's next attempt keeps itsIf-Match); when it reachesterminal.fingerprint_max_attempts, the fallback of Design note 8 runs in the same unit. Audit allowhost.manage(Design note 11).confirmHostdeclaresIdempotency-Key. With a key, the M1-20 stage owns the unit of work, the use case'sDojoins 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 theDofunction so it commits, then return the error. With a key, the handler calls the newidempotency.KeepEffectOnFinal(ctx)(ininternal/kernel/idempotencynext toMarkOneTimeSecret; the stage ininternal/platform/httpserver, M1-20) before it writes409 fingerprint_mismatch. For a marked final answer the stage stores it withStore.Completein the same unit and commits, so the counter, the fallback of Design note 8, the audit entry,host.updatedand 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 offingerprint_mismatch, because every failed attempt must count also under a key (TM-T19). Ruling for docs/DECISIONS.md:attempts_remainingis a new problem member, because screen A8 (M4-37) shows the remaining attempts and no other field carries them. f. Prefix match: assignsession_usersthrough the assignment function of M2-39 (pointer/session_users/<i>/os_user; a name not inreported_session_usersgives400 validation_failedwitherrors[].codesession_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 gives409 conflictwithreason: session_user_taken); then declarellm_credentialsandcredential_holder_idthrough the declaration function of M2-40, which checks I6 before the commit (409 i6_violationwith 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_failuresunchanged. g. Thendeclared_server_host,confirmed_by,confirmed_at,fingerprint_failures = 0, stateactivethrough the M2-39 transition,version + 1;host.updated; audit allow; commit;200 HostwithETag. 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. Oneconfirmaudit entry, plus the entries the reused M2-39 and M2-40 functions write (session_user,llm_credentials), all with the samecorrelation_id. A replay with the sameIdempotency-Keygets the stored answer (06 7 point 2 stores400,409and412), so a network retry never burns a second attempt; a new attempt needs a new key. -
Fallback after too many failures. State
pairingthrough the M2-39 transition;noise_public_key,auth_public_jwkandauth_jktset to NULL;key_fingerprintkept (Design note 9);pairing_code_hash= the hash of a fresh code fromPairingCodes.Newthat is never shown, logged or returned, withpairing_expires_at = now, because 03 demands a hash exactly inpairingand no code may work until the owner issues a new one;fingerprint_failures = 0;version + 1;host.updatedwithkey_changed = true;attempts_remaining = 0. A laterconfirmHostgives409 invalid_state_transition; the owner callsissueHostPairingCode. Ruling for docs/DECISIONS.md; update 24 3.2. -
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 inconfirming,activeanddisabledand does not forbid it inpairing). TheHostmapper and thehost.updatedpayload omitkey_fingerprintandnoise_public_keywhilenoise_public_keyis NULL.pairHostcompares the new fingerprint with the retained one: equal gives409 conflictwithreason: host_key_reused; the audit entry ofhost.pairnames 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 everypair. Ruling for docs/DECISIONS.md; update 24 3.3. -
authz.host.pair:Source= the 05 textpublic (valid pairing code), expressionPublic(like the refresh-token text in M1-28); the code is proven by the use case beforeauthorizeruns, as M1-28 does for refresh tokens. The visibility rowhost(M2-39) becomes "effective owner, the holder of the host, a grantterminal, or any principal when the requirement of the action is exactlyPublic" (onlyhost.pairtoday). Removehost.pairfrom the deferred golden list in the same change;VerifyRegistrationsthen accepts the route (AZ-P1). Ruling for docs/DECISIONS.md: the visibility clause. -
Re-pairing (
POST /api/v1/hosts/{host_id}/pairing-code,host.manage, step-up,rl.admin,x-idempotency-exempt: auth_flow): load,authorizeas in note 7b,reasonas in note 7c; one rights-change unit as in note 7d;RepairEffects(state):retiredgives409 invalid_state_transition;pairingreplaces the code only;confirming,activeanddisabledalso drop the keys;activealso ends every session of the host that is notclosedwithclose_reason = 'host_rekeyed'through the M2-39 function in the same transaction;disabledalso clearsdisabled_reasonanddisabled_at(the only way out ofduplicate_connection, 24 3.4). ThenissueCode, statepairing,version + 1,host.updatedwithkey_changed = trueexactly when keys were dropped (ruling for docs/DECISIONS.md: "dropped stored keys" is the one condition forkey_changed), and201 HostPairingCodewithCache-Control: no-store. A repeated call issues another code and invalidates the previous one (D-T37).key_generationrises only at the nextpairHost(ruling for docs/DECISIONS.md: it numbers the stored keys that devices pin withnoise_public_key, 24 5.5; update 24 3.3). Audit details (auditing.Attrs; fingerprints asauditing.Codein compact lower-case form, 52 characters of[0-9a-z], which matches the code pattern of SEC-AUD-5; if the merged length bound ofauditing.Codeis below 52, raise the named constant with its test and list it in the report):host.managepairing_code:{host_id, operation, state_before, state_after, old_fingerprint (when keys were dropped), key_generation, sessions_ended, reason};host.manageconfirm:{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.pairallow:{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. -
Configuration.
terminal.report_label_max_charsandterminal.report_max_entriesare new keys of chapter 24 (Place Server); M2-43 reuses them forreport. Start checks inconfigchecks.go:terminal.fingerprint_confirm_charsbetween 1 and the length of the compact fingerprint (a format constant),terminal.fingerprint_max_attemptsat least 1, else a start error naming the key. Ruling for docs/DECISIONS.md. -
Locks, events, concurrency. Lock order 03 6.3:
permission_version(throughtxn.RightsChange), then the host row, thenevent_seqandaudit_seq; several hosts never in one unit. Two parallelpairHostwith one code give one201and one401;confirmHostracingissueHostPairingCodeends in one consistent order (eitheractivethenpairing, orpairingand the confirm answers409 invalid_state_transitionor412 version_conflict); two parallel confirmations give one200. Events go only through the M2-39 writer andeventlog.Appender;host.updatednever carries a key, the code or its HMAC. -
Rulings for docs/DECISIONS.md, one row each with reason: notes 1, 2, 3e (rights version at
host.pair), 3h (pairing_expires_atNULL), 4, 5 (only if 24 4.3 lacks the details), 6, 7e (attempts_remaining;KeepEffectOnFinalforfingerprint_mismatch), 7h, 8, 9, 10, 11 (key_changed,key_generation, audit details), 12, and the decision not to buildterminal.sweephere. 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_RoundTripandTestParse_Rejectsof M1-44 extended to the kindhpc;TestFormat_ChecksumVectorswith the new vector (random partEXAMPLEhostpairingcode000000000000000000000, checksum4SigQv).internal/identity:TestHostPairingCode_MintAndHash(kindhpc, length 60, valid checksum,Hashequals HMAC-SHA-256 with the test pepper over the full string, the secret prints[redacted]throughString,FormatandMarshalJSON);TestHashHostPairingCode_RejectsWithoutLookup(one changed checksum character, kindsagt,app,patandwhi, a random part of 42 characters:ErrTokenMalformed, the repository fake counts zero calls);TestAuthenticator_RejectsPairingCode(Authorization: Bearerwith a generatedhpccode gives401 unauthenticatedwithout a token lookup);TestHostPairingCode_MatchesGitleaksRule(reads.gitleaks.tomlfrom the repository root, extracts theregexandkeywordsof the rulebothy-api-token, and asserts that 100 codes fromNewHostPairingCodematch the regex and contain the keywordbothy_hpc_; gitleaks uses Go'sregexp, 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,ftoCR,fotoCSQG,footoCSQPY,foobtoCSQPYRG,foobatoCSQPYRK1,foobartoCSQPYRK1E8);TestFingerprint_RejectsWrongLength;TestFingerprint_KeyOrderMatters;TestFingerprint_HostVectorsonly 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 withd, withkid, withktyEC, withcrvX25519,xof 31 bytes: the codes of Design note 4b;auth_jktequals the RFC 7638 thumbprint of{"crv","kty","x"});TestValidatePairReport(emptysession_users; two names that differ only in case; a name overhost.os_user_max_chars; a name with/; a working directory label with a bidi character; a label overterminal.report_label_max_chars; one entry overterminal.report_max_entriesin each list; duplicate profile names;host_versionv1.0.0and1.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 toterminal.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, clearsdisabled_reason).FuzzParseHostKeysandFuzzNormalizeFingerprintPrefix: 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:allowforhost.pair; the same host stays invisible to them forhost.read);TestDeferredActionsGoldenwithouthost.pair; the source test of M1-28 for the textpublic (valid pairing code).internal/kernel/errsandinternal/platform/httpserver: the problem shape test of M1-18 withattempts_remainingpresent only onfingerprint_mismatch, including the value0.internal/platform/httpserver(idempotency stage of M1-20):TestIdempotency_KeepEffectOnFinal: only a marked final error commits; a final409marked withKeepEffectOnFinalcommits the handler's write together with the stored answer, and its replay returns that answer withIdempotent-Replay: truewithout running the handler; the same409without the marker rolls the write back and is stored bySaveFinal; a marked401or5xxstill rolls back and stores nothing.internal/terminal/app:TestTMT20_WrongChecksumNoDatabase(TM-T20, "wrong checksum without database access"):PairHostwith a fake unit of work and a fake repository, both counting calls; a code with one changed checksum character and a code of kindagtgive401 unauthenticatedwith zeroDocalls and zero repository calls, and exactly oneRecordDenialwith reasonunauthenticated.internal/wire:TestConfig_FingerprintKeysChecked(terminal.fingerprint_confirm_charsof 0 and above the fingerprint length,terminal.fingerprint_max_attemptsof 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"): aftercreateHostand afterissueHostPairingCodethe full code appears in no row ofhosts,audit_events,event_log,idempotency_keysandriver_joband in no captured log line;pairing_code_hashequals the HMAC of the code; both answers carryCache-Control: no-store.TestPairHost_HappyPath:201 HostPairResultwithhost_id,key_fingerprintequal toComputeFingerprintof the sent keys andinstance_id; the host isconfirming;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_profilesstored;pairing_code_hashandpairing_expires_atNULL; the rights version raised by one; onehost.updatedwithoutkey_changed; one audit allowhost.pairwith the details of Design note 11.TestPairHost_StoresReportedSessionUsers(PLAN row: "pairHoststores the reported names of the session users inhosts.reported_session_users"): the column equals the reported list with names and working directory labels in their order, andgetHostshows it inreported_session_users.TestTMT20_PairingCodeRejections(TM-T20): the same code a second time; a code after the clock passedterminal.pairing_code_ttl; a code with one changed checksum character; a code replaced byissueHostPairingCodewhile the host waspairing; an unknown well-formed code: each gives401 unauthenticated, the bodies are equal exceptcorrelation_id, the host is unchanged, and after flushing the recorder the audit holds aggregated denials ofhost.pairwith reasonunauthenticated.TestPairHost_ReportRules: one integration case per rule group of Design note 4 gives400 validation_failedwith the pointer anderrors[].codenamed there, and the same code then pairs successfully (the code stays valid after a400).TestPairHost_RejectsPrivateJWKMember: a JWK withdgives400 validation_failedwith pointer/auth_public_jwk/danderrors[].codeunknown_member; nothing of it appears in any row or log.TestPairHost_KeyInUse: the Ed25519 key of another host gives409 conflictwithreason: 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 tobothy-s-robinand Sam tobothy-s-sam:200 HostinactivewithETag;confirmed_by,confirmed_at, the declaration columns,declared_server_hostand twohost_session_usersrows stored;fingerprint_failures = 0; the rights version raised;host.updated; theconfirmaudit entry; Robin'sauth_sessions.step_up_consumed_atunchanged.TestTMT19_FingerprintMaxAttempts(TM-T19), run twice, once with a freshIdempotency-Keyper attempt and once without the header: wrong prefixes up toterminal.fingerprint_max_attempts: every answer is409 fingerprint_mismatchwithattempts_remainingcounting down,versionstays the same until the last attempt, the sameIf-Matchworks for every attempt; the last answer carriesattempts_remaining0, the host ispairing, the three key columns are NULL, the code used before gives401 unauthenticated, a confirmation with the right prefix gives409 invalid_state_transition,host.updatedcarrieskey_changed = true; a new code fromissueHostPairingCodeand new keys pair again (key_generation = 2), and the right prefix confirms.TestConfirmHost_WrongPrefixFormatNotCounted: a prefix withOand one of the wrong length give400 validation_failedwitherrors[].codeinvalid_format;fingerprint_failuresunchanged; the first mismatching request with anIdempotency-Keygives409 fingerprint_mismatchandfingerprint_failures = 1, and its replay with the same key returns the stored409whilefingerprint_failuresis still1.TestTMT28b_ConfirmSessionUserTaken(TM-T28 (b) overconfirmHost): (1) host active with Sam onbothy-s-sam,updateHostends Sam's assignment, re-pairing and pairing, then a confirmation assigningbothy-s-samto Alex; (2) one confirmation with Sam and Alex both onbothy-s-sam; (3) one confirmation with Sam onbothy-s-samand onbothy-s-alex; (4) after a re-pairing in which Robin kept his valid assignment, a confirmation giving Robinbothy-s-alex: each gives409 conflictwithreason: session_user_taken; nothing changed (hostconfirming, no new row, no declaration,fingerprint_failuresunchanged).TestTMT28e_ConfirmNameNotReported(TM-T28 (e) overconfirmHost): a confirmation assigningbothy-s-other, which the host did not report, gives400 validation_failedwitherrors[].codesession_user_not_on_hostand pointer/session_users/0/os_user; nothing is stored.TestConfirmHost_I6Violation: while the host isconfirming, Robin grantsterminalon it to Sam; a confirmation withpersonaland holder Robin gives409 i6_violation, the host staysconfirming, the deny entry exists after the rollback; the same body withorganizationgives200.TestConfirmHost_RightsAndPreconditions: noIf-Matchgives428 precondition_required; a stale one412 version_conflictwithcurrent; hostpairingandactivegive409 invalid_state_transition; Sam as assignee gives403 forbiddenwithreason: owner_only; Sam on a host that is not his gives404 not_foundlike an unknown ID; Robin without step-up gives401 step_up_required.TestIssuePairingCode_Rights: Sam as assignee gives403 forbiddenwithreason: owner_only; without step-up401 step_up_required;retiredgives409 invalid_state_transition; areasonoveraudit.reason_max_charsgives400 validation_failedwitherrors[].codetoo_long.TestRekey_FromActive(PLAN row: "re-pairing discards the keys immediately, ends sessions withhost_rekeyed, ...host.updatedwithkey_changedto all owners"): an active host without assignee, with anactiveand adetachedsession of Robin seeded through the M2-39 test helper (or a test-only insert in the test package ofinternal/terminal/adapters/postgresif M2-39 has none);issueHostPairingCodegives201; in the same transaction the host ispairing, the three key columns are NULL, both sessions areclosedwithhost_rekeyed, the rights version is raised;getHostshows nokey_fingerprintand nonoise_public_key; onehost.updatedwithkey_changed = true, whose receive checkauthorize(principal, host.read, host)allows Robin and Alex (every owner) and givesnot_foundfor Sam; the event row validates againstHostUpdatedEventand holds no key, code or HMAC.TestRekey_AuditNamesBothFingerprints(PLAN row: "audit with the old and the new fingerprint"): thepairing_codeentry names the old fingerprint; afterpairHostwith new keys, thehost.pairentry namesold_fingerprintandfingerprint(different values) andkey_generation = 2.TestPairHost_RejectsReusedKeys: after a re-pairing, the old key pair gives409 conflictwithreason: host_key_reusedand the code stays valid; a new pair gives201.TestRekey_FromConfirmingDisabledAndPairing: fromconfirmingthe keys are dropped andkey_changed = true; fromdisabledwithduplicate_connection(set by a test-only update of the row, because only M2-43 sets this reason) the host ispairingwithdisabled_reasonanddisabled_atNULL; frompairingonly the code is replaced, withoutkey_changed, and the old code gives401 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 inaudit_events,event_logpayloads, problem bodies or captured logs; the audit details contain only the keys of Design note 11.TestPairingConcurrency: two parallelpairHostwith one code give one201and one401; two parallel confirmations give one200and one412 version_conflictor409 invalid_state_transition;confirmHostracingissueHostPairingCodenever leaves the hostactivewith NULL keys.TestPairingRoutes_AZP1:issueHostPairingCode,pairHostandconfirmHostare 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 gitleaksdetects a generated code in the test run"): builds a code of kindhpcat runtime withsecrets.token_bytes(32), Base62 andzlib.crc32, writes it to a temporary file and asserts that the pinned gitleaks binary with.gitleaks.tomlreports the rulebothy-api-token.test_checksum_vectors_match_go(M1-44) recomputes the newhpcvector.
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.pymake gitleaksmake codegen-check,make arch,make api-lint,make api-checks,make api-tables,make register-checkmake config-referencethenmake config-doc-checkmake adr-indexmake docsmake check
Acceptance criteria¶
-
issueHostPairingCode,pairHostandconfirmHostare enabled and registered; AZ-P1 passes;host.pairis 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. -
pairHoststores both public keys, the fingerprint, the raisedkey_generation, platform, versions, profiles and the reported names of the session users inhosts.reported_session_users, and the host isconfirming. - TM-T19: a wrong fingerprint gives
409 fingerprint_mismatchwithattempts_remaining; afterterminal.fingerprint_max_attemptsfailures the host ispairingand its keys are dropped, with and withoutIdempotency-Key. - TM-T28 (b) and (e) over
confirmHost: a taken name or a second valid assignment gives409 conflictwithreason: session_user_taken, a name the host did not report400 validation_failedwitherrors[].codesession_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 writeshost.updatedwithkey_changedthat 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 checkis 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,identityandauthzupdated (pairing, confirmation, re-pairing, the shared issuer, the kindhpc, the requirement ofhost.pair). - Rulings of Design note 14 recorded in
docs/DECISIONS.mdsection 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.mdentry 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 checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - 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. - Review and red team verdicts and the findings fixed.
- 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/hostif M2-38 was not merged). - 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
4110in M2-43; the jobterminal.sweepand 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>.