Skip to content

M2-38: Host service module and Noise core in Go and Dart

Status: ready | Depends on: M1-01 | Milestone: M2

Goal

After this ticket the host service has its own Go module services/host (module path github.com/OWNER/bothy/services/host) with VERSION, CHANGELOG.md, a component Makefile whose release-build builds the unsigned bothy-host binaries for the five platforms of 18 "Components", and the three packages every later terminal ticket builds on: internal/noise (the handshake Noise_NK_25519_ChaChaPoly_SHA256 over github.com/flynn/noise, initiator and responder, the prologue "bothy-terminal/1" || session ID || host ID, transport states with the 65535-byte limit of the Noise specification), internal/frame (the frames start to replay_end of 24 "Frames") and internal/fingerprint (24 "Fingerprint"). The pure Dart package packages/bothy_noise implements the same handshake, transport, frames and fingerprint on the primitives of the package cryptography, for the desktop app, the mobile app and the web console. Both implementations produce the same bytes for the public test vectors of cacophony and noise-c (TM-T01) and for each other's checked-in recordings in both directions (TM-T02), and in both a wrong prologue, a wrong static key or a swapped host ID make the handshake fail. Why: the terminal stream must keep its content from relay, proxy, logs and a database dump (24 "Protection goal", SEC-TERM-1, T-TM-01) with a small, checked standard instead of own cryptography (ADR 0068, R-TM-02), and device (Dart) and host (Go) must agree byte for byte before any session, relay or view is built on them (M2-42 to M2-48, M4-35). The Go library is little maintained (RK-28); the vectors hold it, and the report states its version and its advisories.

Read first

  • docs/PLAN.md: section 3 "Way of working per ticket"; section 5 "Test strategy" (row "Security": Go fuzzing of the parsers, Noise frames included); section 8 "M2 Agent platform, extensions and terminal": "Goal", "Scope" (terminal part: services/host, Noise and session token, package bothy_noise without UI), "Out of scope", "Exit criteria" item 8, "Test strategy" (Noise with the public test vectors and the cross test Go against Dart; Go test device with the host's Noise package), "Red team" (terminal paragraph), "Dependencies" (M2-38 depends on no ticket of the milestone), "Risks" (little maintained Go library); block "terminal" (normative sources, order "Noise core" first) with the rows M2-38 (this ticket), M2-42 and M4-35 (they depend on this ticket), M2-41, M2-44, M2-45, M2-46, M2-47 and M2-48 (they build the session, pairing and relay parts on it), M4-01 (pub workspace) and M7-23 (release chain of the host service); section 19: P-05, P-09, P-10, P-11, P-12, P-20, P-23.
  • Dependencies: the header names M1-01, and its closure holds nothing else. D-W-102 struck no dependency from this ticket's plan column (it already named only M1-01), so there is no struck ticket to read. Whether M1-05 (server module), M1-74 (CodeQL for Go) or M4-01 (pub workspace) are merged when you start is open; Design notes 1, 3 and 8 say what to do in each case.
  • docs/spec/24-terminal.md (English, normative): 1 "Purpose and limits"; 2.1 "Overview"; 2.2 "Host service bothy-host" (first bullets: own Go binary in services/host, service user holds the keys and speaks Noise, the local channel carries only the decrypted frames of 5.4); 4.1 "Two key pairs per host"; 4.3 "Fingerprint"; 5.1 "Protection goal"; 5.2 "Protocol" (pattern, prologue, messages, size, libraries, test vectors); 5.3 "Terminal session token" (claims sid and cek and the check order that M2-45 builds on this ticket); 5.4 "Frames"; 5.5 "Key pinning on the device"; 6.1 "Opening"; 6.2 "Detach and reattach" (replay_begin, replay_end); 6.3 "Limits"; 10.1 "Audit" ("Never in the audit"); 10.3 "Reasons for ending (terminal_sessions.close_reason)"; 10.4 "Privacy"; 13 "Errors" (close codes 4101, 4104); 14 "Tests" (TM-T01, TM-T02 here, the rest for the owners named below); 15 "Milestones"; 16 (R-TM-01, R-TM-02, R-TM-03, R-TM-10, R-TM-16); 17 "Configuration values of this chapter".
  • docs/spec/15-client-architecture.md: 3 "Workspace", 3.1 "Dependency rules" (rule 7: bothy_noise imports no package:flutter and has no dependency with crypto code except cryptography), 23.1 "Packages and ports" (row bothy_noise: initiator, responder only for tests, transport, prologue, frames, fingerprint; no Flutter, no network, no storage), 23.2 "Opening, streaming and reattaching" (steps 2 to 6), 23.5 "Tests" (row "Unit").
  • docs/spec/02-architecture.md: 6 "Code layout" (block services/host/, the list "Places that carry the product name"); 7.1 "Module register as the source" and 7.2 "go-arch-lint" (they describe the server module only); 7.3 row "Generated files current".
  • docs/spec/18-release.md: 2 "Components" (row host, rules 1 and 4), 9.1 "Host service and server".
  • docs/spec/23-quality.md: 1 "Principles", 2 "Gates", 3 "Component contract" with "Documentation in code", 10 (R-QA-02, R-QA-14), 11 (quality.coverage.go_min_percent, quality.coverage.dart_min_percent, repo.max_file_kb).
  • docs/spec/19-security.md: SEC-TERM-1, SEC-TERM-2, SEC-T18. For the terminal security group only: docs/spec/07-permissions.md 2.12 "Hosts and the terminal capability", SR10, SR11, SR12; docs/spec/08-identity.md 6.3 "Fresh step-up for the terminal" and ID-T28.
  • docs/api/openapi.yaml: schemas NoiseFrame, TerminalSessionOpen (cols, rows with minimum 1 and maximum 65535, client_ephemeral_key_hash), TerminalSessionGrant (noise_protocol, prologue_version, noise_public_key, key_fingerprint), Host (noise_public_key, key_fingerprint example), HostPairRequest (noise_public_key, auth_public_jwk). docs/api/asyncapi.yaml: channels terminalStream and hostData (x-close-codes 4002, 4101, 4104; x-protocol-versions).
  • ADRs: docs/adr/0068-terminal-through-a-host-service-with-noise-stream-and-invariant-i6.md (decision points 1 to 3, "Confirmation", option "Host service, relay, Noise NK"), 0003-license-apache-2-0-with-dco-instead-of-cla.md, 0005-go-as-the-server-language.md, 0036-releases-semver-per-component-signed-sbom-provenance.md, 0060-release-automation-in-the-monorepo.md, 0067-jose-profile-with-the-standard-library.md (the session token is not parsed here), 0070-terminal-in-the-web-console.md (limits of pinning 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-T9, D-T10, D-T19, D-T38, D-N02, D-N05, D-N10, D-OP-03, D-W-100, D-W-101, D-W-102; section 10 for the format of new rows. No other D-W ruling names M2-38.
  • docs/security/threat-model.md 5.16 "Terminal" and 6.7 "Terminal" (T-TM-01 to T-TM-14). docs/RISKS.md: RK-28. 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/quality/gates.md, docs/quality/toolchain.md, docs/quality/licenses.md, docs/quality/red-team-gate.md, docs/quality/push-discipline.md, quality/licenses/allowed.txt, CONTRIBUTING.md ("Development setup").
  • Briefs: docs/briefs/M1-01-server-gates.md (gates arch, codegen-check, migrations-lint, perf, tools/ci/go_prereqs.py, tools/ci/codegen_check.py, tools/ci/tests/test_make_server_gates.py); M1-05-server-skeleton.md (precedent for go.mod lines, .golangci.yml, the command dispatcher of cmd/bothy); M1-74-codeql-go.md (the CodeQL rule for every tracked services/*/go.mod); M2-23-generated-clients.md (precedent: the first component of a kind exercises its lines of the root Makefile and fixes them).
  • Code on the base branch: root Makefile (GO_MODS, DART_PKGS, GO_ON, DART_ON, want, skip, targets arch, codegen-check, perf, fmt-check, lint, typecheck, test, coverage, osv, licenses, sonar); tools/ci/components.py and tools/ci/tests/test_components.py; tools/ci/license_check.py; .github/workflows/ci.yml (jobs gates and sonar), .github/workflows/codeql.yml; sonar-project.properties; .gitleaks.toml; .pre-commit-config.yaml; services/server/go.mod and services/server/.golangci.yml if M1-05 is merged; services/README.md, services/host/README.md, packages/README.md.
  • External, read only for the implementation: Noise Protocol Framework revision 34 (sections 4 to 7, 7.5 pattern NK, 12.3 the ChaChaPoly nonce), the Noise wiki page "Test vectors" (file format), the API documentation of github.com/flynn/noise and of the Dart package cryptography.

This ticket touches no API operation, no table of chapter 03, no action id, no event and no configuration key.

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. This ticket adds no configuration key. Protocol constants of the Noise specification and of 24 (65535 bytes per Noise message, the 16-byte tag, the frame type bytes, field widths, the labels bothy-terminal/1 and bothy-host-fp/1) are constants in code with a comment naming their source, not configuration keys: changing one breaks the protocol, and only a new protocol version may change them (02 "Code layout", D-N10).
  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. Protocol Noise_NK_25519_ChaChaPoly_SHA256 after revision 34, pattern NK (<- s, ..., -> e, es, <- e, ee): in Go with github.com/flynn/noise (HandshakeNK, DH25519, CipherChaChaPoly, HashSHA256, Config.Prologue), in Dart as an own small handshake on X25519, ChaCha20-Poly1305, SHA-256 and HMAC from cryptography (24 5.2, ADR 0068 point 2, R-TM-02).
  2. Prologue "bothy-terminal/1" || session ID (16 bytes) || host ID (16 bytes); a handshake with a different prologue fails (24 5.2, D-T9). The identifiers bothy-terminal/1 and bothy-host-fp/1 change only with a new protocol version (02 "Code layout", D-N10).
  3. Message 1 (device to host) carries the session token as payload, message 2 (host to device) the host's payload; after message 2 both sides hold transport states; every Noise message is at most 65535 bytes, and one WebSocket binary message carries exactly one Noise message (24 5.2, NoiseFrame).
  4. Frames after the handshake: one type byte and a body, the types 0x01 start to 0x07 replay_end with their directions from 24 5.4; an unknown type ends the session with close code 4104 (24 5.4, asyncapi terminalStream); the relay sees only length and time (SEC-TERM-1).
  5. Fingerprint base32_crockford(SHA-256("bothy-host-fp/1" || noise_public_key || auth_public_key_raw)), shown in groups of four characters (24 4.3).
  6. TM-T01: the handshake in Go and in Dart reproduces the entries for Noise_NK_25519_ChaChaPoly_SHA256 of vectors/cacophony.txt (cacophony) and tests/vector/noise-c-basic.txt (noise-c) byte for byte; both files lie under services/host/testdata/noise/ with commit hash and license and serve Go and Dart (24 5.2, D-T19).
  7. TM-T02: cross test Go against Dart over checked-in recordings in both directions; with a wrong prologue, a wrong static key or a swapped host ID only the correct configuration succeeds (24 14, PLAN row).
  8. bothy_noise imports no package:flutter, does no networking and no storage, and has no dependency with crypto code except cryptography; the responder exists only for tests (15 3.1 rule 7, 15 23.1).
  9. The frame parser is fuzzed with Go fuzzing (PLAN 5 row "Security", PLAN row).
  10. The license gate is green for github.com/flynn/noise and cryptography, and the report states the version, release date and open security advisories of both (PLAN row, 24 5.2, RK-28, docs/quality/licenses.md "License gate (make licenses)").
  11. Component contract (23 3): Go module with go.mod, go.sum, tests, golangci-lint configuration in the module, coverage at least quality.coverage.go_min_percent, and a component Makefile with release-build (VERSION, OUT); Dart package with pubspec.yaml, tests under test/, coverage at least quality.coverage.dart_min_percent, doc comments enforced by public_member_api_docs; services/host has VERSION and CHANGELOG.md (18 2 rule 1).
  12. No terminal content, no key material and no session token in any error message, log line or test failure message of these packages (SEC-TERM-1, 24 10.1 "Never in the audit", R-TM-10).

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"). This ticket adds no right, action or decision; M2-39 builds the target host and host.read (AZ-M60), M2-40 builds terminal.*, SR10, SR11 with I6 and SR12 (AZ-M54 to AZ-M59, AZ-M61, TM-T08, TM-T09, TM-T28 (a)). What this ticket enforces: the prologue binds every stream to its session and its host, so a server decision for one host and session cannot be replayed into a handshake for another; proven by the TM-T02 cases "wrong prologue" and "swapped host ID" in Go and Dart.
  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 (ID-T28 without the console part), 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. bothy_noise holds no step-up and no rights logic.
  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 no network code: internal/noise, internal/frame and internal/fingerprint import no net package and bothy_noise imports no dart:io in lib/ (tests TestPackagesImportNoNetwork and test_bothy_noise_has_no_flutter_network_or_storage); the end-to-end layer is what keeps content from the relay. M2-43 builds the outbound control connection, M2-44 the data connection and relay; on an answer "yes" to OQ-24 those two change, not this one.
  4. Audit without content: M2-46 owns audit and metrics without content (TM-T06), M2-44 the relay that logs only length, time and session ID. This ticket's packages write no logs, and their errors carry codes, lengths and type bytes only, never plaintext, payloads, keys or tokens (tests TestErrorsCarryNoSecrets in Go and errors carry no content in Dart).
  5. Idle timeout and maximum duration (24 "Limits"): M2-45 measures idle on the host from the input frames, M2-46 adds the server side (terminal.idle_timeout plus terminal.idle_grace, terminal.max_session_duration, TM-T11). This ticket provides the input classification both build on: IsInput is true exactly for data and resize from the device, and a data frame without bytes is rejected, so an empty frame can never count as input (tests TestFrameIsInput, TestDecodeMalformed and their Dart twins).
  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 contains no privileged code, stores no key and starts no process; nothing to enforce here.
  7. Threats of threat model 6.7 that concern this ticket, each with its test: T-TM-01 (content readable in the server) by the end-to-end handshake and transport, TM-T01 and TM-T02 here in Go and Dart (TM-T06 by M2-46); T-TM-02 (replay and token swap at the relay) by the prologue binding (TM-T02 negative cases here), the helper for cek (TestEphemeralKeyHash and its Dart twin) and the typed frames that let M2-45 start the PTY only after start (TM-T03 to TM-T05 by M2-45); T-TM-06 (pairing of a foreign host) by one fingerprint computation in Go and Dart (TestComputeKnownVector, fingerprint vectors match), whose confirmation M2-41 builds (TM-T19, TM-T20) and whose display at pair M2-42 builds. Not touched here: T-TM-03 and T-TM-09 (M2-40, M2-44), T-TM-04, T-TM-05 storage, T-TM-12 and T-TM-14 (M2-42, M2-43, M2-45), T-TM-07 (M6-18), T-TM-08 (M2-40, M2-42, M2-45), T-TM-10 (M4-35), T-TM-11 (M2-45, M2-46), T-TM-13 (M2-42, M2-45).

Scope

Paths are relative to the repository root. This ticket creates no migration and no new top-level make target; release-build and recordings live in services/host/Makefile.

Create:

  • services/host/go.mod, services/host/go.sum, services/host/VERSION (0.0.0), services/host/CHANGELOG.md (Keep a Changelog, [Unreleased] with ### Added), services/host/.golangci.yml, services/host/.go-arch-lint.yml (Design notes 1 and 2).
  • services/host/Makefile with release-build and recordings (Design note 4).
  • services/host/cmd/bothy-host/main.go, commands.go and tests: the command dispatcher without commands (Design note 4).
  • services/host/internal/noise/: doc.go, noise.go (constants, ParseID, Prologue, EphemeralKeyHash, key pairs), handshake.go (Initiator, Responder), transport.go, errors.go, tests, fuzz targets (Design note 5).
  • services/host/internal/frame/: doc.go, frame.go, tests, FuzzDecode (Design note 6).
  • services/host/internal/fingerprint/: doc.go, fingerprint.go, crockford.go, tests (Design note 7).
  • services/host/testdata/noise/: README.md (English), vectors/cacophony.txt.gz, vectors/noise-c-basic.txt.gz, vectors/LICENSE.cacophony, vectors/LICENSE.noise-c (Design note 10), recordings/go.json, recordings/dart.json (Design note 11), fingerprint.json (Design note 7).
  • packages/bothy_noise/: pubspec.yaml, pubspec.lock (Design note 8), analysis_options.yaml, README.md (English), CHANGELOG.md, lib/bothy_noise.dart, lib/testing.dart, lib/src/*.dart, test/*.dart (Design notes 8 and 9).
  • tools/ci/tests/test_host_release_build.py, tools/ci/tests/test_ci_toolchains.py, tools/ci/tests/test_bothy_noise_package.py, tools/ci/tests/test_make_host_and_dart_lines.py (Tests).

Change:

  • Root Makefile: PERF_MODULES and the perf recipe; the Dart branches of test and coverage (Design note 3).
  • .github/workflows/ci.yml (jobs gates and sonar): Go for every tracked services/*/go.mod, Flutter for every tracked Dart package (Design note 3). .github/workflows/codeql.yml only under the condition of Design note 3.
  • tools/ci/components.py and tools/ci/tests/test_components.py: services/host/testdata/noise/ selects apps as well (Design note 3).
  • sonar-project.properties: coverage report paths of the new components, **/testdata/** excluded (Design note 3).
  • docs/spec/24-terminal.md, docs/spec/02-architecture.md, docs/spec/15-client-architecture.md, docs/spec/23-quality.md (English, Design note 13).
  • docs/adr/0068-terminal-through-a-host-service-with-noise-stream-and-invariant-i6.md: "Confirmation" names the tests of this ticket for TM-T01 and TM-T02; then make adr-index.
  • docs/quality/gates.md, docs/quality/toolchain.md (English), CONTRIBUTING.md ("Development setup": Go from services/*/go.mod, Flutter from the pinned pubspec.yaml, the host service and the Dart packages).
  • services/host/README.md, services/README.md, packages/README.md (English, status and layout).
  • docs/RISKS.md RK-28: the pinned versions and the date of the check in the mitigation column, facts only.
  • docs/DECISIONS.md section 10: rows D-M2-38-<n> (Design note 14). Root CHANGELOG.md under [Unreleased]: the gate and CI changes.

Out of scope

  • Session token: claims, verification order, memory of jti, comparison of cek, checks of osu, wdi, prf and own, the payload JSON of message 2 (v, host_version, profile), starting the PTY only after start, mapping decode and handshake errors to 4101 and 4104 and to close_detail, TM-T03 to TM-T05, TM-T21: M2-45. A version constant of the host service (host_version): the first ticket that reports it (M2-42 or M2-43).
  • Persisting keys, host_service.state_dir with 0600 or DPAPI, install, doctor, pair and its fingerprint display, the local channel: M2-42.
  • Control and data connections, hello and identify, close codes 4110 to 4114: M2-43. Sessions, relay and /terminal/sessions/{id}/stream: M2-44. Several nodes: M2-47.
  • Server side of pairing and the server's own fingerprint computation, TM-T19, TM-T20: M2-41; fingerprint.json is written so that the server implementation can be checked against the same vectors, and M2-42 (pair against the real server) depends on both. Module terminal: M2-39. Rights and step-up: M2-40. Revocation, limits, audit, metrics, TM-T06, TM-T10, TM-T11: M2-46. Conformance and chaos suite, Go test device, host service in a Linux container, TM-T16: M2-48.
  • terminal_ui, pin store, renderer xterm, TM-T14, TM-T18: M4-35. The Dart architecture test, dart doc in the documentation site, the workspace itself: M4-01 (Design note 8 for the takeover).
  • Signatures, SBOM, checksums, installation packages, release.yml and tags host/v<version>: M7-23. Terminal in dev environments: V2. File transfer and clipboard frames: V3-16.
  • Changes to the core contract.

Boundaries

  • Work in a dedicated git worktree on the branch feat/M2-38-noise-core; one writer per worktree.
  • Conventional Commits with the component as scope (for example feat(host): ..., feat(bothy-noise): ..., build(gates): ..., ci: ..., docs(spec): ...) 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. Never publish bothy_noise (publish_to: none).
  • 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 open no listener and make no network call; they read only files of this repository and temporary directories. Run Go with GOTOOLCHAIN=local.
  • Stop only processes you started, by PID. Temporary directories only via mktemp -d (Go t.TempDir(), pytest tmp_path), removed at the end.
  • Never read or print secrets. No network calls except package downloads for pinned tools, Go modules and pub packages, what the gates of make check do themselves, read-only version queries of the Go module proxy and of pub.dev for the report, and one download of each of the two vector files at a recorded commit (Design note 10).
  • The Flutter SDK (stable, at least the version 15 2 names) and Go must be on PATH; without them the gates fail by design (R-QA-02), and the ticket stops with Verdict: BLOCKED <tool> missing instead of skipping.
  • Never lower a threshold, disable a gate, delete a test or exclude these files from the pre-commit hygiene hooks; never raise repo.max_file_kb; never extend quality/licenses/allowed.txt.
  • The product name appears only at the places of 02 "Code layout" ("Places that carry the product name"): module path, binary bothy-host, the labels bothy-terminal/1 and bothy-host-fp/1, the package name bothy_noise, the release component host. Test labels and seeds use neutral strings.
  • 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>.

Design notes

  1. Go module. module github.com/OWNER/bothy/services/host; the go and toolchain lines equal those of services/server/go.mod when that file is tracked, otherwise follow the M1-05 rule (go names the older maintained Go line, toolchain the exact version go version prints on the build host, at least go1.27.2, ADR 0005), and test_go_directives_agree holds both modules equal from then on. Ruling for docs/DECISIONS.md: the module path follows P-05 with the placeholder OWNER (V1-03 replaces it mechanically), and both Go modules share one Go line, because CI installs one Go and ADR 0005 names one line. Dependencies: the newest release of github.com/flynn/noise (v1.1.0 per 24 5.2; if a newer one exists, take it and update the version and date in 24 5.2), and raise its indirect golang.org/x/crypto and golang.org/x/sys with go get to their newest release, because OSV-Scanner reads go.mod and reports every advisory of a listed version, reachable or not. No other dependency; tests use the standard library only. .golangci.yml: a copy of services/server/.golangci.yml with the goimports local prefix set to the host module path; if M1-05 is not merged, write it with the settings of the M1-05 brief ("Lint and Sonar": revive with exported and package-comments, gosec, errorlint, the formatters, never the exclusion preset comments) and validate it with golangci-lint config verify.

  2. Architecture rules of the host module. M1-01's arch requires <module>/.go-arch-lint.yml for every tracked Go module, and 02 7.1 makes the register services/server/architecture/modules.yaml the source for the server only. Ruling for docs/DECISIONS.md: the host module has no register; its .go-arch-lint.yml is maintained by hand with one component per package, because the host is one small binary without the server's modules, ports and tables, and a register format for it would be a second generator without a second user. Content (verify against the pinned go-arch-lint with make arch):

    # SPDX-License-Identifier: Apache-2.0
    # Architecture rules of the host service. Maintained by hand: one component per package,
    # vendors only where named (docs/spec/02-architecture.md, "Code layout"). Later tickets add
    # their packages here in the same change (an unattached package fails make arch).
    version: 3
    workdir: .
    allow:
      depOnAnyVendor: false
    excludeFiles: ["^.*_test\\.go$"]
    vendors:
      v-noise: { in: [github.com/flynn/noise] }
    components:
      cmd:         { in: cmd/** }
      noise:       { in: internal/noise }
      frame:       { in: internal/frame }
      fingerprint: { in: internal/fingerprint }
    deps:
      noise: { canUse: [v-noise] }
    

    frame and fingerprint use only the standard library; cmd imports no internal package yet (go-arch-lint rejects an empty mayDependOn, so cmd has no deps entry until M2-42 adds one). Record the hand-maintained file in 02 "Code layout" and in the row "Go module" of 23 3.

  3. Root Makefile, CI and gate wiring for a second Go module and the first Dart package.

    • perf: M1-01 fails make perf for every Go module without perf/, but 24 names no performance target for the host service. Add PERF_MODULES := services/server with a comment ("Go modules with performance targets: 02 "Performance targets", R-AR-20; the host service has none, 24") and run the perf loop over $(filter $(PERF_MODULES),$(GO_MODS)); when that list is empty print SKIP perf no Go module with performance targets selected with the skip macro. A listed module without perf/ still fails as M1-01 built it. Ruling for docs/DECISIONS.md. arch, codegen-check and migrations-lint work per module unchanged (codegen-check runs go generate ./..., which has nothing to do here; migrations-lint prints its per-module SKIP); run each once and fix only what fails.
    • Dart lines: bothy_noise is the first tracked Dart package, so the Dart lines of fmt-check, lint, typecheck, test and coverage run for the first time. Run each with COMPONENTS=apps and fix what does not work. Two defects are known and are fixed here: test runs flutter test only if [ -d test ], so a package with code but without tests passes silently; it now fails with <package>: no test/ directory (docs/spec/23-quality.md, 3). coverage skips a package without coverage/lcov.info with continue; it now fails with <package>: coverage/lcov.info is missing; make test writes it. Both follow 23 1 point 3 and R-QA-02. Ruling for docs/DECISIONS.md.
    • .github/workflows/ci.yml, jobs gates and sonar: the setup-go step runs if: hashFiles('services/*/go.mod') != '' with go-version-file: ${{ hashFiles('services/server/go.mod') != '' && 'services/server/go.mod' || 'services/host/go.mod' }} and cache-dependency-path: services/*/go.sum; the flutter-action step runs if: hashFiles('apps/*/pubspec.yaml', 'packages/*/pubspec.yaml') != '' with flutter-version-file: ${{ hashFiles('apps/client/pubspec.yaml') != '' && 'apps/client/pubspec.yaml' || 'packages/bothy_noise/pubspec.yaml' }}. Action pins stay. If the base branch already installs Flutter for every tracked Dart package with another pin source (M4-01 merged), keep that source and adapt test_ci_toolchains.py to it. make runners-check and make lint (actionlint, zizmor) must stay green.
    • .github/workflows/codeql.yml: only if tools/ci/tests/test_codeql_workflow.py (M1-74) is on the base branch, add for services/host the setup-go step with go-version-file: services/host/go.mod and the build step go build ./... in services/host with CGO_ENABLED=0, in the positions that test requires; otherwise change nothing (M1-74 covers every module tracked when it runs). Dart is not supported by CodeQL.
    • tools/ci/components.py: GATE_COMPONENTS["apps"] gains the prefix services/host/testdata/noise/, because the Dart tests read the vectors, recordings and fingerprint vectors there; a change in that folder then runs both the Go and the Dart gates in CI. Ruling for docs/DECISIONS.md.
    • sonar-project.properties: sonar.go.coverage.reportPaths gains services/host/coverage.out, sonar.dart.lcov.reportPaths gains packages/bothy_noise/coverage/lcov.info; add **/testdata/** to sonar.exclusions if M1-05 has not added it.
  4. Command dispatcher and release-build. 23 3 asks for release-build with VERSION and OUT, and the plan row puts it into this ticket, but a release build needs a main package, which M2-42 fills with commands. Ruling for docs/DECISIONS.md: this ticket creates cmd/bothy-host as the dispatcher without commands, exactly like cmd/bothy of M1-05 (main calls os.Exit(run(ctx, os.Args[1:], os.Stdout, os.Stderr)); commands.go holds a table of command{name, summary string; run func(ctx context.Context, args []string, stdout, stderr io.Writer) int}; no arguments: usage on stderr, exit 2; help, -h, --help: usage on stdout, exit 0; unknown command: bothy-host: unknown command "<name>" plus usage on stderr, exit 2; standard library flag only). M2-42 registers install, pair, doctor, run, restart, session-user and pty-helper (02 "Code layout").

    • services/host/Makefile (SPDX header, SHELL := bash, .SHELLFLAGS := -euo pipefail -c like the root Makefile, ## help texts): PLATFORMS := darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64 with a comment naming 18 "Components" row host.
    • make -C services/host release-build VERSION=<x.y.z> OUT=<dir>: without VERSION or OUT print usage: make release-build VERSION=<x.y.z> OUT=<dir> and exit 2; if VERSION differs from the content of services/host/VERSION, print release-build: VERSION file says <file>, not <given> and exit 1 (the wording of the root target; make itself then exits 2, as GNU make reports every failed recipe with status 2, so tests tell the usage error from the version mismatch by the message, not the exit code); else for each platform CGO_ENABLED=0 GOTOOLCHAIN=local GOOS=<os> GOARCH=<arch> go build -trimpath -o <OUT>/bothy-host_<version>_<os>_<arch>[.exe] ./cmd/bothy-host, .exe only for Windows. Ruling for docs/DECISIONS.md: unsigned binaries without cgo; M7-23 adds signature, SBOM, checksums and installation packages and may change the output layout; M2-45 may switch on cgo for a platform with a recorded reason.
    • make -C services/host recordings: UPDATE_RECORDINGS=1 go test -count=1 -run 'TestRecordingsCurrent|TestFingerprintFileCurrent' ./internal/noise ./internal/fingerprint (Design notes 7 and 11).
  5. internal/noise (Go). Package comment: purpose, the protocol name, the source sections of 24, that the package does no I/O.

    • Constants: ProtocolName = "Noise_NK_25519_ChaChaPoly_SHA256", PrologueLabel = "bothy-terminal/1", MaxMessageLen = 65535, TagLen = 16, MaxPlaintextLen = MaxMessageLen - TagLen, KeyLen = 32, IDLen = 16, each with its source in a comment.
    • ParseID(s string) ([IDLen]byte, error): only the canonical 36-character form 8-4-4-4-12, hex digits in either case; the 16 bytes in the octet order of RFC 9562 (the bytes as the string reads left to right); no version check, because the prologue binds bytes and the server validates its own IDs. Errors wrap ErrInvalidID. Ruling for docs/DECISIONS.md (byte order of the IDs in the prologue).
    • Prologue(sessionID, hostID [IDLen]byte) []byte: PrologueLabel (16 ASCII bytes) || session ID || host ID, 48 bytes.
    • EphemeralKeyHash(public []byte) (string, error): SHA-256 of the 32-byte X25519 public key, base64url without padding (openapi client_ephemeral_key_hash, claim cek, 24 5.3); another length gives ErrInvalidKey.
    • KeyPair{Private, Public [KeyLen]byte}; GenerateKeyPair(random io.Reader) (KeyPair, error) (production passes crypto/rand.Reader); KeyPairFromPrivate(private [KeyLen]byte) (KeyPair, error) for recordings and tests.
    • InitiatorConfig{HostStatic [KeyLen]byte; SessionID, HostID [IDLen]byte; Ephemeral KeyPair}: the ephemeral key is required, because the device computes cek from it before the session is opened (15 23.2 step 2). NewInitiator(cfg) (*Initiator, error); (*Initiator).WriteMessage1(payload []byte) ([]byte, error); (*Initiator).ReadMessage2(msg []byte) (payload []byte, t *Transport, err error).
    • ResponderConfig{Static KeyPair; SessionID, HostID [IDLen]byte; Ephemeral *KeyPair; Random io.Reader} (nil Ephemeral: generated from Random, default crypto/rand.Reader). NewResponder(cfg) (*Responder, error); (*Responder).ReadMessage1(msg []byte) (payload []byte, initiatorEphemeral [KeyLen]byte, err error) (M2-45 compares the hash of initiatorEphemeral with cek); (*Responder).WriteMessage2(payload []byte) ([]byte, *Transport, error). Both types have HandshakeHash() []byte after completion. An unexported constructor with a raw prologue serves the vector tests.
    • Transport: Encrypt(plaintext []byte) ([]byte, error) and Decrypt(msg []byte) ([]byte, error) with the cipher states of Split (initiator to responder, responder to initiator). One goroutine may encrypt while another decrypts; neither method is safe for concurrent calls of itself (doc comment).
    • Limits, checked by the wrapper before the library is called, so that the behaviour does not depend on the library version: a handshake message longer than MaxMessageLen or a payload that would make one longer gives ErrMessageTooLong; Encrypt with more than MaxPlaintextLen bytes gives ErrMessageTooLong without using a nonce; Decrypt with more than MaxMessageLen or fewer than TagLen bytes fails.
    • Ruling for docs/DECISIONS.md: after the first failed Decrypt, or any failed handshake step, the object refuses every further call with ErrBroken, because a failed authentication means manipulation (24 5.3 "hard warning", 4104) and continuing would let a relay probe; the wrapper keeps its own message counter per direction and refuses at 2^64 - 1 (ErrNonceExhausted, Noise revision 34 section 5.1 reserves that nonce); calls in the wrong order give ErrState.
    • Ruling for docs/DECISIONS.md: a Diffie-Hellman result of 32 zero bytes (low-order public key) fails the handshake in both languages. Verify that the pinned library's DH25519 already returns an error for it; if not, use a DHFunc that wraps it and rejects the zero result. Reason: the Dart side must behave the same, and an all-zero shared secret means a key that an attacker chose.
    • Use Config.EphemeralKeypair of the library for a fixed ephemeral key if the pinned version has it, otherwise a deterministic Config.Random reader; PeerEphemeral() and ChannelBinding() (or their equivalents) give the initiator's ephemeral key and the handshake hash.
  6. internal/frame (Go). type Type byte with TypeStart 0x01, TypeData 0x02, TypeResize 0x03, TypeClose 0x04, TypeExit 0x05, TypeReplayBegin 0x06, TypeReplayEnd 0x07; type Sender uint8 with FromDevice and FromHost; type Frame struct{Type Type; Cols, Rows uint16; Data []byte; ExitCode int32}; constructors Start, Data, Resize, Close, Exit, ReplayBegin, ReplayEnd; Encode(f Frame) ([]byte, error); Decode(b []byte, from Sender) (Frame, error) (the returned Data aliases b, documented); (Frame).IsInput() bool; MaxLen = 65519 with the comment "largest plaintext of a Noise transport message: 65535 minus the 16-byte tag (24 5.2, 5.4)" and a test that it equals noise.MaxPlaintextLen. The package does not import noise; the local channel of M2-42 uses the same frames.

    • Ruling for docs/DECISIONS.md (update 24 5.4), because 24 5.4 names field widths but no byte order or body rules: all integers big-endian; start and resize carry exactly 4 bytes, columns then rows, each 1 to 65535 (as TerminalSessionOpen); data carries 1 to 65518 bytes (an empty data would count as input without input, 24 6.3); close, replay_begin and replay_end carry no body; exit carries exactly 4 bytes, a signed 32-bit two's complement integer.
    • Directions: start, resize and close only from the device; exit, replay_begin and replay_end only from the host; data both ways. A type byte outside 0x01 to 0x07 gives ErrUnknownType; a known type in the wrong direction gives ErrWrongDirection; a wrong body gives ErrMalformed; an empty input ErrEmpty; more than MaxLen bytes ErrTooLong (also in Encode). Ruling for docs/DECISIONS.md: every decode error ends the session like an unknown type (4104, 24 5.4, asyncapi terminalStream); M2-45 does the mapping.
    • IsInput is true exactly for data and resize (24 6.3 "Idle").
  7. internal/fingerprint (Go). Label = "bothy-host-fp/1"; Encode(b []byte) string: 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; Compute(noisePublicKey, authPublicKey []byte) (string, error): both exactly 32 bytes (the raw X25519 key and the raw Ed25519 key, the decoded x of the JWK), result the 52 characters in 13 groups of four joined by - (the form of key_fingerprint in openapi.yaml); Compact(display string) string removes the separators. Ruling for docs/DECISIONS.md (update 24 4.3).

    • 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.
    • services/host/testdata/noise/fingerprint.json (written by make -C services/host recordings, compared byte for byte by TestFingerprintFileCurrent): {"format": 1, "vectors": [{"noise_public": "<hex>", "ed25519_public": "<hex>", "fingerprint": "<display form>"}, ...]}, first the known vector, then eight entries whose keys are SHA-256 of fingerprint/noise/<i> and fingerprint/auth/<i> for i from 1 to 8 (the function hashes bytes and needs no valid curve point). Field names avoid the keywords of the default gitleaks rule generic-api-key (for example auth, key, token, secret, api, access); make gitleaks must stay green with the file committed. The Dart test fingerprint vectors match and the paragraph on fingerprint.json in testdata/noise/README.md use the same field names.
  8. Dart package bothy_noise. pubspec.yaml: name: bothy_noise, description, version: 0.0.0, publish_to: none, environment with sdk from the Dart version of the pinned Flutter release (caret range) and flutter with the exact Flutter version flutter --version prints on the build host (stable, at least the release 15 2 names); dependencies: only cryptography (newest 2.x, 2.9.0 per 24 5.2; on a newer one update 24 5.2); dev_dependencies: test and lints. Ruling for docs/DECISIONS.md: until the client app has its own pubspec.yaml, the flutter line of this package is the Flutter pin that CI reads (flutter-version-file), because the root Makefile runs the Dart gates with flutter and CI must install exactly one known version; the package still imports no Flutter (15 3.1 rule 7).

    • Workspace: if the base branch has M4-01's root pubspec.yaml with a workspace: list, add packages/bothy_noise to it, set resolution: workspace in the package and track no pubspec.lock of its own; otherwise track packages/bothy_noise/pubspec.lock (OSV-Scanner and the license gate read it) and leave the takeover to the ticket that creates the workspace. Ruling for docs/DECISIONS.md.
    • Tests use package:test and run with flutter test --coverage (the root Makefile; flutter test accepts a dependency on test or flutter_test). Only if it refuses package:test, switch the tests to flutter_test as a dev dependency, record each SDK package that OSV-Scanner then reports as unknown in a PackageOverrides entry with reason in packages/bothy_noise/osv-scanner.toml (licenses.md point 3), and record the switch in the report.
    • analysis_options.yaml: include: package:lints/recommended.yaml, strict-casts, strict-inference, strict-raw-types, and the lint public_member_api_docs (23 "Documentation in code"; M4-01 adds the gate test and dart doc in the site).
    • Public library package:bothy_noise/bothy_noise.dart: constants noiseProtocolName, prologueLabel, maxNoiseMessageLength, noiseTagLength, maxTransportPlaintextLength, maxFrameLength; Uint8List uuidToBytes(String uuid); Uint8List buildPrologue(List<int> sessionId, List<int> hostId); Future<SimpleKeyPair> generateEphemeralKeyPair(); Future<String> ephemeralKeyHash(List<int> ephemeralPublicKey); class NkInitiator (static Future<NkInitiator> start({required List<int> hostStaticPublicKey, required List<int> sessionId, required List<int> hostId, required SimpleKeyPair ephemeral}), Future<Uint8List> writeMessage1(List<int> payload), Future<NkResult> readMessage2(List<int> message)); class NkResult (payload, transport, handshakeHash); class NoiseTransport (Future<Uint8List> encrypt(List<int> plaintext), Future<Uint8List> decrypt(List<int> message)); NoiseException with NoiseError (handshakeFailed, decryptFailed, messageTooLong, nonceExhausted, broken, invalidState, invalidKey, invalidId); the sealed class TerminalFrame with StartFrame, DataFrame, ResizeFrame, CloseFrame, ExitFrame, ReplayBeginFrame, ReplayEndFrame and the getter isInput; Uint8List encodeFrame(TerminalFrame frame); TerminalFrame decodeFrame(List<int> bytes, FrameSender from) with enum FrameSender {device, host}; FrameException with FrameError (empty, unknownType, wrongDirection, malformed, tooLong); String crockfordBase32(List<int> bytes); Future<String> hostKeyFingerprint(List<int> noisePublicKey, List<int> authPublicKey). Same rules and values as the Go side in notes 5 to 7.
    • package:bothy_noise/testing.dart exports only NkResponder (static Future<NkResponder> start({required SimpleKeyPair staticKeyPair, required List<int> sessionId, required List<int> hostId, SimpleKeyPair? ephemeral}), Future<Uint8List> readMessage1(List<int> message), the getter initiatorEphemeralPublicKey, Future<(Uint8List, NoiseTransport)> writeMessage2(List<int> payload)). Ruling for docs/DECISIONS.md: the responder is a separate library because 15 23.1 allows it only for tests, while M4-35 can use it as a fake host.
    • lib/ imports neither package:flutter nor dart:io, dart:html or any networking package, so the same code runs in the apps and in the web console; tests may use dart:io to read files.
  9. Noise state machine in Dart. Implement CipherState, SymmetricState and the NK handshake of revision 34: h is the protocol name itself (exactly 32 bytes), ck = h, MixHash(prologue), the pre-message MixHash(rs); message 1 e (MixHash(e.public)), es (MixKey(DH(e, rs))), EncryptAndHash(payload); message 2 e, ee, EncryptAndHash(payload); Split with the Noise HKDF (HMAC-SHA-256: temp = HMAC(ck, ikm), out1 = HMAC(temp, 0x01), out2 = HMAC(temp, out1 || 0x02)), first cipher initiator to responder. ChaChaPoly nonce: 4 zero bytes then the 64-bit counter little-endian (section 12.3); associated data h during the handshake, empty in transport; ciphertext and tag concatenated. Primitives: X25519, Chacha20.poly1305Aead(), Sha256 and Hmac.sha256() of cryptography. The same limits, the zero-result rejection, broken after a failure and the nonce limit as in note 5. encrypt takes its nonce synchronously when called, so results belong to the call order (doc comment: send them in that order).

  10. Test vector files. Fetch vectors/cacophony.txt from https://github.com/centromere/cacophony and tests/vector/noise-c-basic.txt from https://github.com/rweather/noise-c, each at the commit of the default branch on the build day, with its license file; store each unchanged but gzip-compressed (gzip -n -9) as vectors/<name>.txt.gz, and the license as vectors/LICENSE.<project>. Ruling for docs/DECISIONS.md: compressed storage keeps the upstream content byte for byte below repo.max_file_kb, keeps the hygiene hooks from rewriting it and keeps the published test keys out of the secret scanners' text rules. testdata/noise/README.md (English) has a table with the columns File, Source (URL and path), Commit (40 hex digits), License (SPDX) and SHA-256 of the content (of the uncompressed file), and a paragraph on the recordings and fingerprint.json. A license must be in quality/licenses/allowed.txt or a public-domain dedication such as Unlicense; test data is never shipped (licenses.md applies to shipped parts), and the README records the license. Any other license: stop with Verdict: BLOCKED <file and license>. Ruling for docs/DECISIONS.md. Loader rules (Go and Dart): the file is JSON with a list vectors; the protocol of an entry is protocol_name, else name, and only when neither exists Noise_<pattern>_<dh>_<cipher>_<hash>; selected are the entries equal to ProtocolName without a non-empty init_psk, resp_psk, init_psks or resp_psks and without fallback (noise-c-basic.txt also holds NoisePSK_NK_25519_ChaChaPoly_SHA256, a PSK handshake of an older Noise revision that plain NK cannot reproduce); resp_prologue defaults to init_prologue; messages alternate initiator and responder, the first two are the handshake, the rest transport; an entry with "fail": true must fail at some message; unknown fields are ignored; every file must yield at least one selected entry, else the test fails.

  11. Recordings for TM-T02. testdata/noise/recordings/go.json is written by Go, recordings/dart.json by Dart; each language compares its own file byte for byte with a fresh generation and replays the other's. UPDATE_RECORDINGS=1 makes the test write instead of compare (Go: make -C services/host recordings; Dart: UPDATE_RECORDINGS=1 flutter test test/recordings_test.dart in packages/bothy_noise). Format, two-space indentation, lowercase hex for every byte field, final newline, fields in this order:

    {
      "format": 1,
      "protocol": "Noise_NK_25519_ChaChaPoly_SHA256",
      "generator": "go",
      "scenarios": [
        {
          "name": "open_shell",
          "session_id": "0192f4a0-7b1c-7b22-9d4f-5a6b7c8d9e0f",
          "host_id": "0192f4a0-7b1c-7b22-9d4f-5a6b7c8d9e10",
          "responder_static_private": "<hex>",
          "responder_static_public": "<hex>",
          "initiator_ephemeral_private": "<hex>",
          "initiator_ephemeral_public": "<hex>",
          "responder_ephemeral_private": "<hex>",
          "responder_ephemeral_public": "<hex>",
          "message1_payload": "<hex>",
          "message1": "<hex>",
          "message2_payload": "<hex>",
          "message2": "<hex>",
          "handshake_hash": "<hex>",
          "transport": [
            {"from": "device", "frame": "<hex>", "message": "<hex>"}
          ]
        }
      ]
    }
    

    Private keys are SHA-256 of noise-recording/<generator>/<scenario>/<role> with the roles responder-static, initiator-ephemeral, responder-ephemeral. Go uses session 0192f4a0-7b1c-7b22-9d4f-5a6b7c8d9e0f and host 0192f4a0-7b1c-7b22-9d4f-5a6b7c8d9e10, Dart session 0192f4a0-7b1c-7b22-9d4f-5a6b7c8d9e11 and host 0192f4a0-7b1c-7b22-9d4f-5a6b7c8d9e12. Payload of message 1: the ASCII text EXAMPLE session token <scenario>; of message 2: {"v":1,"host_version":"0.0.0","profile":"shell"}. Scenarios in this order: open_shell (device start 120x40, device data ls\n, host data README.md\r\n, device resize 100x30, device data exit\n, host exit 0); attach_replay (device start 80x24, host replay_begin, host data earlier output\r\n, host replay_end, device close); limits (device start 65535x65535, device data of 65518 bytes with byte i equal to i mod 251, so the Noise message is exactly 65535 bytes, host exit -1).

  12. No content in errors. Errors are sentinel values wrapped with %w (Go) or exceptions with an enum (Dart); their text names the operation, a code, a length or a type byte, never bytes of a payload, plaintext, key or token. The packages write no log lines. Test failure messages print lengths and SHA-256 prefixes, never key or payload bytes.

  13. Specification text (English, spec first). 24 4.3: the Crockford details of note 7, the raw keys, the display form and the known vector. 24 5.2: the 16 bytes of the IDs in RFC 9562 octet order; rejection of a zero Diffie-Hellman result; the vector files stored gzip-compressed with their README; the recordings for TM-T02 in services/host/testdata/noise/recordings/; the library versions if they changed. 24 5.4: byte order, body rules, directions and "every error while reading a frame handled like an unknown type (4104)" from note 6. 02 "Code layout": in the block services/host/ add internal/fingerprint, testdata/noise/ and .go-arch-lint.yml "maintained by hand, one component per package". 15 23.1: the row bothy_noise names the hash of the ephemeral key for client_ephemeral_key_hash and the separate library testing.dart for the responder. 23 3: row "Go module": the host keeps its .go-arch-lint.yml by hand; load tests under perf/ only for modules with performance targets (PERF_MODULES); row "Dart package": a package without test/ or without coverage report fails. Write every file in English (chapters, READMEs and quality pages; CHANGELOGs, this package's doc comments and code), like the existing files.

  14. Rulings for docs/DECISIONS.md, one row each with date, decision, reason and place (D-M2-38-<n>): module path and shared Go line (note 1); hand-maintained .go-arch-lint.yml (note 2); PERF_MODULES (note 3); the two Dart gate fixes (note 3); the CI path prefix (note 3); the dispatcher for release-build and the unsigned binaries without cgo (note 4); ID byte order (note 5); ErrBroken after a failure and the nonce limit (note 5); zero Diffie-Hellman result (note 5); frame byte order, bodies, directions and error mapping (note 6); Crockford and display form (note 7); the Flutter pin in this package (note 8); the workspace takeover (note 8); the separate testing.dart (note 8); compressed vector files and accepted licenses (note 10). Facts already in 24 (pattern, prologue, limits, fingerprint formula) are not repeated there.

Tests (write them first)

Go, services/host (go test -race, standard library only):

  • internal/noise:
    • TestVectorsTMT01: one subtest per selected entry of both vector files (<file>/<index>); initiator and responder built from the entry's keys and prologues with the unexported raw-prologue constructor reproduce every handshake message byte for byte, the handshake hash where present, and every transport message; entries with fail fail; each file yields at least one entry; the NoisePSK_NK_25519_ChaChaPoly_SHA256 entry of noise-c is not selected.
    • TestVectorProvenance: every row of testdata/noise/README.md names an existing .txt.gz, a 40-digit commit, an SPDX license with its LICENSE.<project> file present, and a SHA-256 equal to that of the decompressed content.
    • TestRecordingsCurrent: a fresh generation of recordings/go.json equals the file byte for byte (writes it with UPDATE_RECORDINGS=1).
    • TestRecordingsTMT02Dart: recordings/dart.json replayed as initiator (message 1, the frames from the device and the handshake hash equal byte for byte; message 2 and the frames from the host decrypt and decode to the recorded values) and as responder (message 1 decrypts to the recorded payload and initiator key; message 2 and the frames from the host equal byte for byte).
    • TestRecordingsTMT02Negative: for both recordings and every scenario: a responder whose session ID has its last byte XORed with 0x01, a responder with another static key pair (SHA-256 of noise-recording/wrong-static), and a responder with session and host ID swapped in the prologue all fail ReadMessage1 with ErrHandshake; an initiator with the flipped session ID and one expecting another host key both fail ReadMessage2 on the recorded message 2; the correct configuration succeeds in the same test.
    • TestPrologue (exact 48 bytes for the Go recording IDs, label first), TestParseID (upper and lower case accepted; 35 and 37 characters, braces, urn:uuid: prefix, non-hex rejected).
    • TestEphemeralKeyHash: bytes 0x00 to 0x1f give Yw3NKWbEM2aRElRIu7JbT_QSpJxzLbLIq8G4WBvXEN0; 31 and 33 bytes give ErrInvalidKey.
    • TestMessageLimits: Encrypt of 65519 bytes gives exactly 65535 bytes and the peer decrypts it; 65520 bytes give ErrMessageTooLong and the next valid message still uses the expected nonce; Decrypt of 65536 bytes and of 15 bytes fail; a message-1 payload that would exceed 65535 bytes and a 65536-byte message 1 fail.
    • TestLowOrderKeyRejected: an initiator with an all-zero host key fails WriteMessage1; a message 1 whose ephemeral key is all zero fails ReadMessage1.
    • TestBrokenAfterFailure (one flipped ciphertext bit gives ErrDecrypt, the next valid message ErrBroken), TestNonceExhausted (counter set to 2^64 - 1 through an unexported test hook), TestCallOrder (ErrState), TestConcurrentDirections (one goroutine encrypts while another decrypts, under -race).
    • TestErrorsCarryNoSecrets: with the payload CANARY-7f3a91 and the test keys, no error text of any failing call contains the canary, a key in hex or a key in base64url.
    • TestPackagesImportNoNetwork: go/parser over the non-test files of internal/noise, internal/frame and internal/fingerprint: no import of net, net/... or os/exec.
    • FuzzReadMessage1 and FuzzDecrypt: arbitrary input gives an error or a result, never a panic.
  • internal/frame:
    • TestRoundTrip for every type and allowed direction; TestBigEndian (Start(0x0102, 0x0304) encodes as 01 01 02 03 04); TestExitCodeSigned (-1 as 05 ff ff ff ff, math.MinInt32, math.MaxInt32).
    • TestDecodeUnknownType (0x00 and 0x08 to 0xff, both senders: ErrUnknownType); TestDecodeWrongDirection (start, resize, close from the host; exit, replay_begin, replay_end from the device); TestDecodeMalformed (empty input ErrEmpty; start and resize with 3 or 5 body bytes or with 0 columns or rows; close, replay_begin, replay_end with a body; exit with 3 or 5 bytes; data without bytes).
    • TestFrameSizeLimit (data of 65518 bytes encodes and decodes; 65519 bytes give ErrTooLong in Encode and Decode); TestMaxLenMatchesNoise; TestFrameIsInput (true only for data and resize); TestErrorsCarryNoContent (a malformed data-like input with a canary).
    • FuzzDecode (the plan's fuzzing of the frame parser): for both senders, arbitrary input gives a frame or one of the sentinel errors, never a panic, and a decoded frame re-encodes to exactly the input; seeds: one valid frame of every type and every case of TestDecodeMalformed.
  • internal/fingerprint: TestEncodeRFC4648Vectors (the RFC 4648 section 10 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); TestComputeKnownVector (the vector of note 7); TestComputeRejectsWrongLength; TestDisplayForm (52 characters of the alphabet in 13 groups); TestKeyOrderMatters (swapping both keys changes the result); TestFingerprintFileCurrent.
  • cmd/bothy-host: TestRunWithoutArgumentsPrintsUsageAndExits2, TestRunHelpExits0, TestRunUnknownCommandExits2.

Dart, packages/bothy_noise/test/ (run by flutter test --coverage; a helper walks up from the package to the directory that contains services/host/testdata/noise and fails if it finds none):

  • vectors_test.dart: TM-T01 cacophony and TM-T01 noise-c with the loader rules of note 10 (reading the .gz with GZipCodec); the NoisePSK_NK_25519_ChaChaPoly_SHA256 entry of noise-c is not selected.
  • recordings_test.dart: TM-T02 replays the Go recording as initiator, TM-T02 replays the Go recording as responder, TM-T02 wrong prologue, wrong static key and swapped host ID fail (the cases of TestRecordingsTMT02Negative on both recordings), Dart recording is current (writes with UPDATE_RECORDINGS=1).
  • handshake_test.dart: prologue bytes, uuidToBytes cases, ephemeralKeyHash literal, message limits, zero key rejected, broken after failure, nonce limit, call order, errors carry no content (the cases of the Go tests).
  • frames_test.dart: the cases of the Go frame tests, including isInput.
  • fingerprint_test.dart: the RFC 4648 table, the known vector, fingerprint vectors match (every entry of fingerprint.json).

Repository tools, tools/ci/tests/ (pytest, tmp_path):

  • test_host_release_build.py: test_release_build_without_arguments_is_usage_error (exit 2 and the output contains usage: make release-build VERSION=<x.y.z> OUT=<dir>); test_release_build_rejects_other_version (VERSION=9.9.9: make exits 2, because GNU make reports every failed recipe with status 2, and the output contains release-build: VERSION file says <file>, not 9.9.9); test_release_build_writes_one_binary_per_platform (the version from the file, OUT in tmp_path): exactly the five names of note 4, each with the right format and machine: ELF (7f 45 4c 46) with e_machine 0x3e (amd64) or 0xb7 (arm64), Mach-O 64-bit (cf fa ed fe) with cputype 0x01000007 or 0x0100000c, PE (MZ) with machine 0x8664.
  • test_make_host_and_dart_lines.py (a temporary git repository with a copy of the root Makefile, an empty tools/ and fake uv, flutter and dart executables first on PATH; MAKEFLAGS and MAKELEVEL dropped from the environment): test_perf_skips_go_module_without_performance_targets (a tracked services/host/go.mod without perf/: make perf exits 0 with the SKIP line); test_perf_still_requires_perf_dir_of_listed_module (a tracked services/server/go.mod without perf/: exit 2 and the output contains M1-01's message perf: services/server/perf does not exist); test_dart_package_with_tests_runs_flutter_test; test_dart_package_without_tests_fails; test_dart_coverage_below_threshold_fails; test_dart_coverage_without_lcov_fails.
  • test_ci_toolchains.py: test_setup_go_covers_every_go_module and test_flutter_covers_every_dart_package (both jobs gates and sonar, conditions and version-file expressions of note 3); test_go_directives_agree (every tracked services/*/go.mod has the same go and toolchain lines; services/host/go.mod is tracked, so the test is not vacuous); test_flutter_pins_agree (every tracked pubspec.yaml with environment.flutter has one exact x.y.z and all are equal).
  • test_bothy_noise_package.py: test_bothy_noise_dependencies (runtime dependencies exactly cryptography; publish_to: none); test_bothy_noise_has_no_flutter_network_or_storage (no file under lib/ imports package:flutter, dart:io, dart:html or a networking package).
  • test_components.py: new case services/host/testdata/noise/recordings/go.json selects apps and server.
  • Existing guards that must stay green: tools/ci/tests/test_make_server_gates.py (M1-01), tools/ci/tests/test_license_check.py, tools/ci/tests/test_check_runners.py, tools/ci/tests/test_codeql_workflow.py if present, tools/docs/tests/test_toolchain_doc.py.

Test IDs of the plan row: TM-T01 (24 14) in Go and Dart with the same bytes: TestVectorsTMT01 and vectors_test.dart. TM-T02 (24 14) as cross test over checked-in recordings in both directions: TestRecordingsTMT02Dart, TestRecordingsTMT02Negative, recordings_test.dart. Unknown frame type and a message over the limit of the specification rejected: TestDecodeUnknownType, TestMessageLimits, TestFrameSizeLimit and their Dart twins. Fingerprint equal in Go and Dart: TestComputeKnownVector, TestFingerprintFileCurrent, fingerprint vectors match. Go fuzzing of the frame parser: FuzzDecode. License gate: make licenses.

Run each fuzz target once locally with go test -run=^$ -fuzz=<target> -fuzztime=<duration of your choice> ./internal/<package> 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/host && GOTOOLCHAIN=local go vet ./... && GOTOOLCHAIN=local go test -race -count=1 ./...
  • cd packages/bothy_noise && flutter analyze --fatal-infos && flutter test --coverage
  • make -C services/host release-build VERSION=$(cat services/host/VERSION) OUT=<temporary directory>
  • cd tools && uv run --locked --quiet pytest ci/tests/test_host_release_build.py ci/tests/test_make_host_and_dart_lines.py ci/tests/test_ci_toolchains.py ci/tests/test_bothy_noise_package.py ci/tests/test_components.py ci/tests/test_make_server_gates.py
  • make arch, make codegen-check
  • make fmt-check, make lint, make typecheck, make coverage, make licenses, make osv, make runners-check, make docs, make linkcheck
  • make check

make perf is not part of make check (P-07); run it once and show that its output names no failure for services/host (the SKIP line while no module of PERF_MODULES is tracked, otherwise only the result M1-01 gives for services/server).

Acceptance criteria

  • TM-T01: Go and Dart reproduce every selected Noise_NK_25519_ChaChaPoly_SHA256 entry of the cacophony and noise-c files byte for byte; both files are stored with commit, license and content hash, and TestVectorProvenance holds them.
  • TM-T02: Go replays the Dart recording and Dart replays the Go recording as initiator and as responder with equal bytes; a wrong prologue, a wrong static key and a swapped host ID fail in both languages, and only the correct configuration succeeds.
  • An unknown frame type, a frame in the wrong direction, a malformed frame and a Noise message over 65535 bytes are rejected in Go and Dart; the body rules, byte order and directions stand in 24 5.4.
  • The fingerprint is equal in Go and Dart, including the known vector of note 7 and every entry of fingerprint.json; the encoding stands in 24 4.3.
  • FuzzDecode exists, runs its seeds in go test, and a local fuzz run is reported.
  • make licenses and make osv are green with github.com/flynn/noise and cryptography; the report states version, release date and advisories of both.
  • services/host has go.mod, go.sum, VERSION, CHANGELOG.md, .golangci.yml, .go-arch-lint.yml and a Makefile whose release-build writes the five binaries; make arch passes for it; Go coverage is at least quality.coverage.go_min_percent.
  • bothy_noise imports no Flutter, no dart:io and no networking in lib/, depends only on cryptography, exposes the responder only in testing.dart, and reaches quality.coverage.dart_min_percent.
  • make perf no longer fails for the host module; a Dart package without tests or without coverage report fails make test or make coverage; CI installs Go and Flutter for the new components; services/host/testdata/noise/ selects the Dart gates.
  • No error text, log line or test failure message contains a payload, plaintext, key or token.
  • 24, 02, 15 and 23 are updated as in Design note 13; ADR 0068 "Confirmation" names the tests; the rulings of note 14 are recorded.
  • make check is green.

Definition of done

  • Code and tests as specified; doc comment on every public symbol in Go and Dart (purpose, parameters, return value, errors, example where useful); comments explain why.
  • READMEs of services/host, services/host/testdata/noise, packages/bothy_noise, services and packages written or updated (layout, how to regenerate the recordings and fingerprint vectors, release-build, what the packages guarantee and what M2-42, M2-45 and M4-35 add).
  • docs/quality/gates.md, docs/quality/toolchain.md, CONTRIBUTING.md and RK-28 in docs/RISKS.md updated; ADR 0068 "Confirmation" names the tests; make adr-index run.
  • Rulings of Design note 14 recorded in docs/DECISIONS.md section 10 (one row each with reason); the changes to 24, 02, 15 and 23 made in the same change.
  • services/host/CHANGELOG.md, packages/bothy_noise/CHANGELOG.md and the root CHANGELOG.md entries 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. Name in the red team request the terminal points of PLAN 8 "Red team" this ticket touches: replay of the first message and token swap at the relay (prologue binding, cek helper), content in errors and logs, and the library state.
  • 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 (the compressed vector files with their size), 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, for example: the binaries of release-build were built but not run on macOS and Windows; the Dart code ran in the Dart VM, not in a browser.
  5. Library state: the pinned versions of github.com/flynn/noise, golang.org/x/crypto and cryptography, the release date of each (Go module proxy, pub.dev), every advisory OSV knows for them including fixed ones with the fixing version, and the result of make osv and govulncheck; the commits and licenses of both vector files; the Flutter and Dart versions pinned; the fuzz runs with duration and result.
  6. Follow-ups that could not be done in this scope, each with its reason, every difference to the M1 names of this brief (D-W-100), and spec gaps found.

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